Add shop, cart, versand pages and Impulse & Tipps detail page
- New /shop overview, /cart (real cart state via useSyncExternalStore), /versand (shipping policy page with TOC) and /newsletter detail page - Cart: quantity/removal, order summary, related-products cross-sell with randomized picks, VersandModal quick-reference instead of navigating away, MwSt. disclosure next to unit prices - Add-to-cart UX: inline success feedback (green state) plus a fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed badge count-up; AddToCartButton no longer navigates straight to /cart - Shared lib/products.ts catalog and lib/shipping.ts constants (cost, free-shipping threshold, handling/transit days) so cart, trust badges and the versand page can never drift apart - Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no more perceived start delay); compress newsletter modal photo 2.4MB -> 85KB to fix first-open jank
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useCart, removeFromCart, setQuantity } from "../../lib/cart";
|
||||
import { PRODUCTS, formatPrice } from "../../lib/products";
|
||||
import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { VersandModal } from "../../components/VersandModal";
|
||||
|
||||
export function CartContent() {
|
||||
const [versandOpen, setVersandOpen] = useState(false);
|
||||
const cart = useCart();
|
||||
const items = cart
|
||||
.map((entry) => ({ entry, product: PRODUCTS[entry.id] }))
|
||||
.filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product));
|
||||
|
||||
const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0);
|
||||
const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
|
||||
const total = subtotal + shipping;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page header */}
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Warenkorb</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Warenkorb
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
{items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Reveal className="flex flex-col gap-6 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="text-body text-text-body">
|
||||
Schau dir unsere Produkte an und finde, was zu dir passt.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
) : (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
{/* Cart card — lg:-only split from the sidebar (same "wide content
|
||||
next to sidebar" shape as the Hero's image/text split, see
|
||||
figma-to-nextjs skill Gotcha #5: Figma's 830px card alone
|
||||
already exceeds the 768px Tablet floor, so md: would never
|
||||
have had room for a real 2-column layout at Tablet widths
|
||||
anyway). */}
|
||||
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
|
||||
{items.map(({ entry, product }, i) => (
|
||||
<div key={product.id} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
|
||||
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="text-label text-text-muted">Einzelpreis</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<label className="sr-only" htmlFor={`qty-${product.id}`}>
|
||||
Menge für {product.name}
|
||||
</label>
|
||||
<select
|
||||
id={`qty-${product.id}`}
|
||||
value={entry.qty}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * product.price)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromCart(product.id)}
|
||||
aria-label={`${product.name} entfernen`}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Reveal delay={0.1} className="w-full lg:w-[20.625rem] lg:shrink-0 flex flex-col gap-6 items-start">
|
||||
<div className="bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Bestellübersicht
|
||||
</p>
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0
|
||||
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span
|
||||
className="font-semibold text-h4 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Zur Kasse gehen
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-[0.625rem] items-center justify-center w-full">
|
||||
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
|
||||
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
{[
|
||||
{ icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" },
|
||||
{ icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" },
|
||||
{ icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" },
|
||||
].map((b) => (
|
||||
<div key={b.text} className="flex gap-3 items-center w-full">
|
||||
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
|
||||
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
|
||||
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
|
||||
<div className="h-[0.1875rem] w-6 bg-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail.
|
||||
</p>
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Für mehr Klarheit, Fokus und Struktur – jede Woche.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { PRODUCTS, RELATED_PRODUCT_IDS, formatPrice } from "../../lib/products";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton";
|
||||
import { useCart } from "../../lib/cart";
|
||||
|
||||
const ALL_PRODUCT_IDS = Object.keys(PRODUCTS);
|
||||
const DISPLAY_COUNT = 3;
|
||||
|
||||
function pickRandom(excludeIds: string[], count: number): string[] {
|
||||
const pool = ALL_PRODUCT_IDS.filter((id) => !excludeIds.includes(id));
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
export function RelatedProducts() {
|
||||
const cart = useCart();
|
||||
const hasItems = cart.length > 0;
|
||||
const cartKey = cart
|
||||
.map((i) => i.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
// Math.random() can't run during the render that has to match the SSR
|
||||
// pass (that would be a hydration mismatch — server and client would
|
||||
// pick different products) — so the first paint always shows the fixed
|
||||
// RELATED_PRODUCT_IDS fallback, and a client-only effect swaps in the
|
||||
// real, cart-aware random pick right after mount.
|
||||
const [displayIds, setDisplayIds] = useState<string[]>(RELATED_PRODUCT_IDS);
|
||||
|
||||
// First run (mount/hydration): pick the initial random set. After that,
|
||||
// don't reshuffle everything on every cart change — one of these cards'
|
||||
// own AddToCartInlineButton adds to the cart without navigating away,
|
||||
// and a full reshuffle would yank the other two cards out from under
|
||||
// the user mid-browse. Instead, only swap out whichever displayed card
|
||||
// just became "already in the cart" (no longer relevant to recommend)
|
||||
// and top that one slot back up — the rest stay exactly where they were.
|
||||
const pickedRef = useRef(false);
|
||||
const swapTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(swapTimeoutRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
const cartIds = cartKey ? cartKey.split(",") : [];
|
||||
|
||||
if (!pickedRef.current) {
|
||||
pickedRef.current = true;
|
||||
setDisplayIds(pickRandom(cartIds, DISPLAY_COUNT));
|
||||
return;
|
||||
}
|
||||
|
||||
// Delayed by FEEDBACK_MS, not immediate — the add usually came from
|
||||
// clicking one of THESE cards' own AddToCartInlineButton, which shows
|
||||
// a ~2s green "Hinzugefügt ✓" state. Swapping the card out right away
|
||||
// unmounts that button before the confirmation is ever visible.
|
||||
clearTimeout(swapTimeoutRef.current);
|
||||
swapTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayIds((prev) => {
|
||||
const stillRelevant = prev.filter((id) => !cartIds.includes(id));
|
||||
const missing = DISPLAY_COUNT - stillRelevant.length;
|
||||
if (missing <= 0) return stillRelevant;
|
||||
|
||||
let replacements = pickRandom([...cartIds, ...stillRelevant], missing);
|
||||
|
||||
// The catalog only has 5 products — once the cart holds 3+
|
||||
// distinct ones, "3 recommendations that aren't already in the
|
||||
// cart" becomes mathematically impossible (5 - 3 in cart leaves
|
||||
// only 2 to show). Falling back to re-suggesting something
|
||||
// already in the cart (a normal "grab another one" pattern) beats
|
||||
// silently shrinking the grid below DISPLAY_COUNT.
|
||||
if (replacements.length < missing) {
|
||||
const stillMissing = missing - replacements.length;
|
||||
const fallback = pickRandom([...stillRelevant, ...replacements], stillMissing);
|
||||
replacements = [...replacements, ...fallback];
|
||||
}
|
||||
|
||||
return [...stillRelevant, ...replacements];
|
||||
});
|
||||
}, FEEDBACK_MS);
|
||||
}, [cartKey]);
|
||||
|
||||
if (displayIds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{hasItems ? "Passt perfekt dazu" : "Beliebte Produkte"}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
{hasItems ? "Weitere Tools für deinen klaren Alltag." : "Entdecke, was zu dir passt."}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* No separate price-disclosure footnote here — the single
|
||||
"* inkl. MwSt., zzgl. Versandkosten" note lives directly under
|
||||
the cart's own product table instead (CartContent.tsx), close
|
||||
enough on the same page view to cover these cards too.
|
||||
|
||||
Plain divs, not RevealGroup/RevealItem — this is the one grid on
|
||||
the site whose items get swapped after the initial mount (see
|
||||
the swap-in-place effect above). RevealItem has no viewport
|
||||
trigger of its own; it only ever renders visible because it
|
||||
inherits the parent RevealGroup's already-resolved "show" state
|
||||
at first mount. A card swapped in later doesn't get a fresh
|
||||
trigger and can end up stuck at its hidden variant
|
||||
(opacity: 0) — invisible. Not worth chasing a fix for a
|
||||
scroll-reveal nicety on a list that mutates; a static grid
|
||||
renders correctly with no animation risk. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayIds.map((id) => {
|
||||
const product = PRODUCTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 320px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import { CartContent } from "./components/CartContent";
|
||||
import { RelatedProducts } from "./components/RelatedProducts";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
|
||||
// robots: noindex — transactional page (mirrors a specific shopper's cart
|
||||
// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing
|
||||
// this wastes crawl budget and could surface cart state in search results.
|
||||
export const metadata: Metadata = {
|
||||
title: "Warenkorb",
|
||||
description: "Dein Warenkorb bei einfach produktiv.",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CartContent />
|
||||
<RelatedProducts />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -178,47 +178,70 @@ export default function ChallengePage() {
|
||||
{/* ── Hero ── */}
|
||||
<section className="w-full flex items-stretch overflow-hidden" style={{ minHeight: "32rem" }}>
|
||||
|
||||
{/* Left: text + form */}
|
||||
<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">
|
||||
{/* Left: text + form. justify-center removed from here — the
|
||||
outer section's items-stretch already makes this column full
|
||||
height, but centering its whole content (breadcrumb included)
|
||||
made the breadcrumb's Y position drift depending on this
|
||||
page's content length vs. /todo-cards's and /newsletter's own
|
||||
(different) content lengths. Same fix as both of those. */}
|
||||
{/* pt-10 md:pt-12 (not the original py-10 lg:py-14's top value) —
|
||||
matches /todo-cards's and /newsletter's breadcrumb top offset
|
||||
exactly; pb-10 lg:pb-14 keeps this page's original bottom
|
||||
spacing, which was never the inconsistent part. */}
|
||||
<Reveal className="flex flex-col gap-6 px-8 lg:px-[5rem] pt-10 md:pt-12 pb-10 lg:pb-14 w-full lg:w-[52%] lg:shrink-0">
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<p className="text-[0.875rem] text-[#888] flex items-center gap-1.5">
|
||||
<Link href="/" className="hover:text-[#f6a701] transition-colors">Startseite</Link>
|
||||
{/* Breadcrumb — 3 levels (Startseite › Werkzeuge ›
|
||||
7-Tage-Challenge), matching /todo-cards's and
|
||||
/newsletter's pattern; was missing "Werkzeuge" in the
|
||||
middle. Also switched from this page's hardcoded hex
|
||||
colors to the shared text-body-sm/text-text-muted/
|
||||
text-text-primary tokens the other two breadcrumbs use —
|
||||
text-body-sm is 0.875rem, identical to the old literal
|
||||
value, so this is a pure consistency fix, not a visual
|
||||
change. Deliberately NOT part of the centered block below. */}
|
||||
<p className="text-body-sm text-text-muted flex items-center gap-2">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-[#222221]">7-Tage-Challenge</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">Werkzeuge</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">7-Tage-Challenge</span>
|
||||
</p>
|
||||
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className="font-semibold text-[#222221] leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(2.25rem, 5vw, 3.75rem)" }}
|
||||
>
|
||||
7 Tage.<br />
|
||||
Mehr Klarheit.<br />
|
||||
Weniger Stress.
|
||||
</h1>
|
||||
{/* Everything else — centered in the remaining vertical space,
|
||||
same fix as /todo-cards's and /newsletter's Hero sections. */}
|
||||
<div className="flex flex-col gap-6 flex-1 justify-center">
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className="font-semibold text-[#222221] leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)", fontSize: "clamp(2.25rem, 5vw, 3.75rem)" }}
|
||||
>
|
||||
7 Tage.<br />
|
||||
Mehr Klarheit.<br />
|
||||
Weniger Stress.
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="text-[1rem] text-[#444] leading-[1.6] max-w-[30rem]">
|
||||
Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag.
|
||||
</p>
|
||||
{/* Subtitle */}
|
||||
<p className="text-[1rem] text-[#444] leading-[1.6] max-w-[30rem]">
|
||||
Die 7-Tage-Challenge für deinen klaren Kopf und mehr Struktur im Alltag.
|
||||
</p>
|
||||
|
||||
{/* Checklist */}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
"7 kurze Impulse – direkt in dein Postfach",
|
||||
"Praktisch, umsetzbar und alltagstauglich",
|
||||
"Für mehr Fokus, Ruhe und Klarheit",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-[#222221] text-[0.95rem]">
|
||||
<Check />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Checklist */}
|
||||
<ul className="flex flex-col gap-2">
|
||||
{[
|
||||
"7 kurze Impulse – direkt in dein Postfach",
|
||||
"Praktisch, umsetzbar und alltagstauglich",
|
||||
"Für mehr Fokus, Ruhe und Klarheit",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-start gap-2 text-[#222221] text-[0.95rem]">
|
||||
<Check />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Email form */}
|
||||
<EmailCapture />
|
||||
{/* Email form */}
|
||||
<EmailCapture />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Right: hero image with badge — group + scale-105 on hover,
|
||||
@@ -330,8 +353,14 @@ export default function ChallengePage() {
|
||||
</section>
|
||||
|
||||
{/* ── Was andere sagen ── */}
|
||||
{/* max-w-[1600px], not this page's other sections' 1280px — a
|
||||
deliberate compromise with /todo-cards's and /newsletter's
|
||||
unbounded fluid width, so all three testimonial sections cap
|
||||
at the same width instead of Challenge's reading narrower on
|
||||
wide viewports. Only this one section's cap changed, not the
|
||||
rest of the page. */}
|
||||
<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">
|
||||
<div className="px-8 lg:px-[5rem] max-w-[1600px] mx-auto flex flex-col gap-10">
|
||||
|
||||
<Reveal
|
||||
className="font-semibold text-[#222221] text-center"
|
||||
@@ -340,16 +369,23 @@ export default function ChallengePage() {
|
||||
Was andere sagen
|
||||
</Reveal>
|
||||
|
||||
{/* Same micro-interactions as /todo-cards's identically-styled
|
||||
testimonial cards (kept in sync deliberately): hover-lift on
|
||||
{/* Same style + micro-interactions as /todo-cards's and
|
||||
/newsletter's identically-styled testimonial cards (kept in
|
||||
sync deliberately): decorative quote-mark, 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) => (
|
||||
<RevealItem
|
||||
key={t.name}
|
||||
className="group flex-1 bg-[#f5f0e8] rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
className="group relative 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>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="text-[#222221] text-[0.95rem] leading-[1.6] flex-1 pr-8">{t.quote}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
alt={t.name}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const FEEDBACK_MS = 2000;
|
||||
const PRODUCT_ID = "todo-karten";
|
||||
|
||||
/**
|
||||
* Shared by /todo-cards's Hero + pricing panel and Home's product
|
||||
* spotlight. Used to navigate straight to /cart on click; now stays on the
|
||||
* page instead (matching AddToCartInlineButton's behavior everywhere else)
|
||||
* — adds to the cart, plays the fly-to-navbar-icon animation, and shows a
|
||||
* brief inline success state instead of leaving the page.
|
||||
*/
|
||||
export function AddToCartButton({
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
function handleClick() {
|
||||
addToCart(PRODUCT_ID);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
|
||||
}
|
||||
|
||||
const base =
|
||||
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";
|
||||
// Trailing `!` — Tailwind v4's important-modifier syntax moved from a
|
||||
// leading `!` to this trailing suffix — forces the success color to win
|
||||
// over whatever bg-brand/text-text-primary each caller already baked
|
||||
// into `base`, since appending on top can't rely on CSS source order
|
||||
// the way branching a whole className (AddToCartInlineButton's
|
||||
// approach) can when the base itself varies per caller.
|
||||
const stateClasses = added ? "bg-success! hover:bg-success! text-white!" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`${base} ${stateClasses}`}
|
||||
>
|
||||
{added ? "Hinzugefügt ✓" : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { addToCart } from "../lib/cart";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
// Exported so consumers like RelatedProducts.tsx can delay their own
|
||||
// follow-up UI changes (e.g. swapping out this exact card) until after
|
||||
// the success state has actually had time to be seen.
|
||||
export const FEEDBACK_MS = 2000;
|
||||
|
||||
/**
|
||||
* Add-to-cart button that stays on the page (unlike AddToCartButton, which
|
||||
* navigates to /cart) — used wherever a product grid lets you keep browsing,
|
||||
* so a click needs its own success confirmation instead of relying on the
|
||||
* navigation itself as feedback.
|
||||
*/
|
||||
export function AddToCartInlineButton({
|
||||
id,
|
||||
label = "In den Warenkorb",
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [added, setAdded] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { fly } = useCartFly();
|
||||
|
||||
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
||||
|
||||
function handleClick() {
|
||||
addToCart(id);
|
||||
if (buttonRef.current) fly(buttonRef.current);
|
||||
setAdded(true);
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => setAdded(false), FEEDBACK_MS);
|
||||
}
|
||||
|
||||
const base =
|
||||
className ??
|
||||
"flex items-center justify-between px-5 py-3 rounded-sm border w-full transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base";
|
||||
// Branch the border/bg classes instead of appending an "override" on top
|
||||
// of the default ones — Tailwind v4 has no leading-`!` important prefix
|
||||
// anymore (it's a trailing `!` now), so two conflicting utilities like
|
||||
// border-border/border-success both being present would silently race on
|
||||
// CSS source order instead of one cleanly winning.
|
||||
const stateClasses = added
|
||||
? "border-success bg-success-subtle scale-[1.02]"
|
||||
: "border-border hover:border-brand";
|
||||
|
||||
return (
|
||||
<button ref={buttonRef} type="button" onClick={handleClick} className={`${base} ${stateClasses}`}>
|
||||
<span
|
||||
className={
|
||||
"text-body-sm transition-colors " +
|
||||
(added ? "font-semibold text-success" : "text-text-primary")
|
||||
}
|
||||
>
|
||||
{added ? "Hinzugefügt ✓" : label}
|
||||
</span>
|
||||
<img alt="" src="/icon-cart-outline.png" className="h-[1.875rem] w-8 object-contain" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Ball = { id: number; startX: number; startY: number; endX: number; endY: number };
|
||||
|
||||
type CartFlyContextValue = {
|
||||
registerCartIcon: (el: HTMLElement | null) => void;
|
||||
fly: (sourceEl: HTMLElement) => void;
|
||||
/** Real cart count minus this = what the navbar badge should currently
|
||||
* show — held back for as long as a ball is still mid-flight, so the
|
||||
* badge only "counts up" once the ball visually lands there. */
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
const CartFlyContext = createContext<CartFlyContextValue | null>(null);
|
||||
|
||||
let ballSeq = 0;
|
||||
|
||||
/**
|
||||
* Mounted once at the root (app/layout.tsx) so both the cart icon (in
|
||||
* Navbar, one branch of the tree) and any add-to-cart button (in page
|
||||
* content, a different branch) can share one flight target/queue via
|
||||
* context instead of prop-drilling across unrelated component trees.
|
||||
*/
|
||||
export function CartFlyProvider({ children }: { children: ReactNode }) {
|
||||
const cartIconRef = useRef<HTMLElement | null>(null);
|
||||
const [balls, setBalls] = useState<Ball[]>([]);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const registerCartIcon = useCallback((el: HTMLElement | null) => {
|
||||
cartIconRef.current = el;
|
||||
}, []);
|
||||
|
||||
const fly = useCallback((sourceEl: HTMLElement) => {
|
||||
const target = cartIconRef.current;
|
||||
if (!target) return;
|
||||
const from = sourceEl.getBoundingClientRect();
|
||||
const to = target.getBoundingClientRect();
|
||||
setBalls((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: ++ballSeq,
|
||||
startX: from.left + from.width / 2,
|
||||
startY: from.top + from.height / 2,
|
||||
endX: to.left + to.width / 2,
|
||||
endY: to.top + to.height / 2,
|
||||
},
|
||||
]);
|
||||
setPendingCount((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
function handleArrive(id: number) {
|
||||
setBalls((prev) => prev.filter((b) => b.id !== id));
|
||||
setPendingCount((n) => Math.max(0, n - 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<CartFlyContext.Provider value={{ registerCartIcon, fly, pendingCount }}>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{balls.map((ball) => (
|
||||
<motion.div
|
||||
key={ball.id}
|
||||
initial={{ x: ball.startX, y: ball.startY, opacity: 1, scale: 1.4 }}
|
||||
animate={{
|
||||
x: ball.endX,
|
||||
y: ball.endY,
|
||||
scale: 0.6,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
transition={{ duration: 0.85, ease: "easeIn" }}
|
||||
onAnimationComplete={() => handleArrive(ball.id)}
|
||||
style={{ marginLeft: -12, marginTop: -12 }}
|
||||
className="fixed left-0 top-0 z-[100] size-6 rounded-full bg-brand pointer-events-none"
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</CartFlyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCartFly() {
|
||||
const ctx = useContext(CartFlyContext);
|
||||
if (!ctx) throw new Error("useCartFly must be used within CartFlyProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
const links = [
|
||||
{ label: "Impressum", href: "/impressum" },
|
||||
{ label: "Datenschutz", href: "/datenschutz" },
|
||||
{ label: "Versand", href: "/versand" },
|
||||
{ label: "AGB", href: "/agb" },
|
||||
{ label: "Widerruf", href: "/widerruf" },
|
||||
];
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCartCount } from "../lib/cart";
|
||||
import { NewsletterModal } from "./NewsletterModal";
|
||||
import { useCartFly } from "./CartFly";
|
||||
|
||||
const NAVBAR_HEIGHT = 100; // px — matches h-[6.25rem]
|
||||
const SCROLL_DURATION = 800; // ms
|
||||
@@ -17,10 +19,11 @@ function smoothScrollTo(targetY: number) {
|
||||
function step(now: number) {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / SCROLL_DURATION, 1);
|
||||
// ease-in-out cubic
|
||||
const ease = progress < 0.5
|
||||
? 4 * progress ** 3
|
||||
: 1 - (-2 * progress + 2) ** 3 / 2;
|
||||
// ease-out cubic — not ease-in-out. Ease-in-out's cubic ease-in half
|
||||
// stays nearly flat for the animation's first ~150-200ms, which read
|
||||
// as a delay before the scroll visibly starts. Ease-out moves
|
||||
// immediately and only decelerates into the landing.
|
||||
const ease = 1 - (1 - progress) ** 3;
|
||||
window.scrollTo(0, startY + distance * ease);
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
}
|
||||
@@ -40,12 +43,15 @@ const anchorIds = navLinks
|
||||
.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"];
|
||||
// the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten),
|
||||
// /challenge (7-Tage-Challenge), and /newsletter (Impulse & Tipps) are
|
||||
// all tools listed there, even though none of these routes is nested
|
||||
// under /werkzeuge/. Their own Figma sources (page-todo-karten,
|
||||
// page-weekly-impulses, and the 7-Tage-Challenge card in section-tools)
|
||||
// mark "Werkzeuge" active in the nav for the same reason — confirmed by
|
||||
// page-weekly-impulses's own breadcrumb ("Startseite › Werkzeuge ›
|
||||
// Impulse & Tipps") and active-underline position, same as page-todo-karten's.
|
||||
const WERKZEUGE_ROUTES = ["/todo-cards", "/challenge", "/newsletter"];
|
||||
|
||||
function isNavLinkActive(href: string, pathname: string, activeSection: string): boolean {
|
||||
if (href === "#werkzeuge") {
|
||||
@@ -67,13 +73,54 @@ function isNavLinkActive(href: string, pathname: string, activeSection: string):
|
||||
// Badge only renders once something is actually in the cart.
|
||||
function CartLink() {
|
||||
const count = useCartCount();
|
||||
const { registerCartIcon, pendingCount } = useCartFly();
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const prevVisibleRef = useRef(0);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Held back by pendingCount while a ball is mid-flight, so the badge only
|
||||
// "counts up" once the ball visually lands here — not the instant the
|
||||
// button is clicked (real cart data itself updates instantly elsewhere,
|
||||
// this is purely the badge's own display value).
|
||||
const visibleCount = Math.max(0, count - pendingCount);
|
||||
|
||||
useEffect(() => {
|
||||
registerCartIcon(anchorRef.current);
|
||||
return () => registerCartIcon(null);
|
||||
}, [registerCartIcon]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleCount > prevVisibleRef.current) {
|
||||
setPulse(true);
|
||||
const t = setTimeout(() => setPulse(false), 350);
|
||||
prevVisibleRef.current = visibleCount;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prevVisibleRef.current = visibleCount;
|
||||
}, [visibleCount]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={anchorRef}
|
||||
href="/cart"
|
||||
aria-label={count > 0 ? `Warenkorb, ${count} Artikel` : "Warenkorb"}
|
||||
onClick={(e) => {
|
||||
// Already on /cart — a real navigation here is a no-op, so treat
|
||||
// the click as "take me back to the top" instead of doing nothing.
|
||||
if (pathname === "/cart") {
|
||||
e.preventDefault();
|
||||
smoothScrollTo(0);
|
||||
}
|
||||
}}
|
||||
aria-label={visibleCount > 0 ? `Warenkorb, ${visibleCount} 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">
|
||||
<svg
|
||||
viewBox="0 0 32 30"
|
||||
className={"h-7 w-7 text-text-primary transition-transform duration-300 " + (pulse ? "scale-110" : "scale-100")}
|
||||
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"
|
||||
@@ -84,9 +131,14 @@ function CartLink() {
|
||||
<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}
|
||||
{visibleCount > 0 && (
|
||||
<span
|
||||
className={
|
||||
"absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white transition-transform duration-300 " +
|
||||
(pulse ? "scale-125" : "scale-100")
|
||||
}
|
||||
>
|
||||
{visibleCount > 99 ? "99+" : visibleCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -98,6 +150,7 @@ export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [newsletterOpen, setNewsletterOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -302,12 +355,18 @@ export function Navbar() {
|
||||
{/* 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"
|
||||
{/* Opens the modal (matches the original Figma prototype:
|
||||
Newsletter-Button → Overlay newsletter-overlay) instead of
|
||||
navigating to /newsletter — that page is reached via the
|
||||
Werkzeuge "Impulse & Tipps" card's "Anmelden" link instead,
|
||||
a different, heavier entry point for a different context. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewsletterOpen(true)}
|
||||
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>
|
||||
</button>
|
||||
<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"
|
||||
@@ -403,13 +462,16 @@ export function Navbar() {
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-3 px-8 pb-8">
|
||||
<Link
|
||||
href="/newsletter"
|
||||
onClick={closeMobile}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
closeMobile();
|
||||
setNewsletterOpen(true);
|
||||
}}
|
||||
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>
|
||||
</button>
|
||||
<Link
|
||||
href="/challenge"
|
||||
onClick={closeMobile}
|
||||
@@ -419,6 +481,8 @@ export function Navbar() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewsletterModal open={newsletterOpen} onClose={() => setNewsletterOpen(false)} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
export function Newsletter() {
|
||||
type NewsletterProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reused as-is (same bordered/muted panel + icon-decoration + form
|
||||
* pattern) on both Home and /newsletter (the "Impulse & Tipps" page) —
|
||||
* their Figma frames use the exact same newsletter-inner component
|
||||
* instance, just with different copy, so title/description are props
|
||||
* instead of a second near-duplicate component.
|
||||
*/
|
||||
export function Newsletter({
|
||||
title = "Starte mit einer Woche voller Klarheit",
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
}: NewsletterProps = {}) {
|
||||
return (
|
||||
<section className="py-16 w-full">
|
||||
|
||||
@@ -30,11 +45,10 @@ export function Newsletter() {
|
||||
className="font-semibold text-h-section leading-normal"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit
|
||||
{title}
|
||||
</p>
|
||||
<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.
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +66,7 @@ export function Newsletter() {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "/icon-sparkle-wrapper.svg",
|
||||
title: "7 Tage. Ein Fokus.",
|
||||
desc: "Tägliche Impulse für mehr Klarheit und weniger Reibung.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-checklist.png",
|
||||
title: "Praktisch & umsetzbar.",
|
||||
desc: "Direkt anwendbare Methoden für deinen Alltag.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-heart.png",
|
||||
title: "Kein Spam. Versprochen.",
|
||||
desc: "Nur wertvolle Inhalte, wenn du sie brauchst.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The Navbar's "Newsletter" CTA opens this modal rather than navigating —
|
||||
* matches the original Figma prototype (Newsletter-Button → Overlay
|
||||
* newsletter-overlay), unlike the Werkzeuge "Impulse & Tipps" card's
|
||||
* "Anmelden" link, which goes to the full /newsletter detail page instead.
|
||||
* Different entry points, different weight: a quick-access nav CTA gets a
|
||||
* lightweight in-place signup, a content card gets the full page.
|
||||
*/
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Body scroll lock while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// Focus management: move focus into the modal on open, trap Tab within
|
||||
// it, close + return focus on Escape — same pattern as the Navbar's
|
||||
// mobile drawer.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled])'
|
||||
);
|
||||
if (!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);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
// AnimatePresence, not a plain `if (!open) return null` — that would
|
||||
// unmount the modal instantly on close with no way to play an exit
|
||||
// animation first. Keeping it mounted (conditionally rendering the
|
||||
// child) lets Framer Motion finish the fade-out before removing it.
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-8 bg-[rgba(134,134,134,0.9)]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="newsletter-modal-heading"
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[75rem] max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="absolute top-6 right-6 z-10 size-6 flex items-center justify-center active:scale-90 transition-transform"
|
||||
>
|
||||
<img alt="" src="/icon-close.png" className="size-full object-contain" />
|
||||
</button>
|
||||
|
||||
{/* modal-top: photo + copy/form, stacked below md */}
|
||||
<div className="flex flex-col md:flex-row items-stretch border-b border-border">
|
||||
<div className="relative w-full md:flex-1 aspect-[4/3] md:aspect-auto">
|
||||
<Image
|
||||
src="/newsletter-modal-photo.jpg"
|
||||
alt="Notizbuch mit Kaffee und Stift"
|
||||
fill
|
||||
sizes="(min-width: 768px) 50vw, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 items-start justify-center flex-1 min-w-0 px-8 py-10 md:px-[4.6875rem] md:py-[6.25rem]">
|
||||
{/* -scale-y-100 is required, not just -rotate-4 — the SVG
|
||||
itself is authored upside-down (matches how Newsletter.tsx
|
||||
uses this exact same asset); without it the icon renders
|
||||
flipped. */}
|
||||
<div className="w-16 h-14 -rotate-4 -scale-y-100">
|
||||
<img alt="" src="/newsletter-icon.svg" className="w-full h-full" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
id="newsletter-modal-heading"
|
||||
className="font-semibold text-h-feature text-text-primary leading-[1.15]"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Starte mit einer Woche voller Klarheit
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-primary">
|
||||
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
|
||||
</p>
|
||||
|
||||
<form className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full 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="w-full bg-brand rounded-sm px-7 py-[0.875rem] font-bold text-h4 text-text-primary text-left hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex gap-2 items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
|
||||
/>
|
||||
<span className="text-label text-text-primary">
|
||||
Ich akzeptiere die Datenschutzerklärung.
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* modal-bottom: 3 feature cards. Figma's export had items-end on
|
||||
this row, but that's a byproduct of nested -scale-y-100
|
||||
flip-wrappers Figma uses for baseline-grid tricks (visually
|
||||
cancels out to top-alignment in the actual design) — taken
|
||||
literally it bottom-aligned the icon with the last line of
|
||||
description text instead of the title, which read as broken.
|
||||
items-start + a fixed icon bounding box (icons have different
|
||||
native proportions, e.g. the sparkle glyph isn't square) fixes
|
||||
it without needing the flip trick. */}
|
||||
<div className="flex flex-col md:flex-row items-start px-8 md:px-20 py-6 md:py-9 gap-8 md:gap-6">
|
||||
{features.map((f) => (
|
||||
<div key={f.title} className="flex-1 flex gap-6 items-start w-full">
|
||||
<div className="h-10 w-10 shrink-0 flex items-center justify-center">
|
||||
<img alt="" src={f.icon} className="max-h-10 max-w-10 object-contain" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 items-start flex-1 min-w-0">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{f.title}
|
||||
</p>
|
||||
<p className="text-body text-text-primary w-full">{f.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
/**
|
||||
* Product teaser for ToDo-Karten, placed after the Werkzeuge section (not
|
||||
* right after the Hero — that already has its own primary CTA, the
|
||||
* 7-Tage-Challenge, and a second strong purchase CTA competing with it
|
||||
* there would dilute focus). Werkzeuge already introduces ToDo-Karten as
|
||||
* a concept with an "Entdecken" link; this is the natural next step —
|
||||
* a concrete way to buy it, right where interest was just built, rather
|
||||
* than dropped at the very top before the page has earned any trust.
|
||||
* Not derived from a Figma frame (page-home never had this section) —
|
||||
* a deliberate, code-only addition, styled to match the /todo-cards
|
||||
* pricing panel it's a teaser for.
|
||||
*/
|
||||
export function ProductSpotlight() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
|
||||
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
|
||||
<Image
|
||||
src="/product-todo-karten.png"
|
||||
alt="ToDo-Karten Set"
|
||||
fill
|
||||
sizes="(min-width: 768px) 380px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start flex-1 min-w-0 w-full">
|
||||
<p className="font-bold text-h-small text-brand">Neu im Shop</p>
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
ToDo-Karten – Kleine Karten. Große Wirkung.
|
||||
</p>
|
||||
<p className="text-body text-text-body">
|
||||
50 hochwertige Karten, die dir helfen, deinen Kopf frei zu bekommen und das Wesentliche zu sehen — analog, minimalistisch, für jeden Tag.
|
||||
</p>
|
||||
<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>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{/* No className override — the section's bg is bg-bg-base now
|
||||
(matches Tools/Blog above/below), same as AddToCartButton's
|
||||
own default styling/ring-offset, so no override is needed
|
||||
here. */}
|
||||
<AddToCartButton label="In den Warenkorb" />
|
||||
<Link
|
||||
href="/todo-cards"
|
||||
className="inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm border border-border text-body font-bold text-text-primary whitespace-nowrap hover:border-brand hover:text-brand active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Mehr erfahren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FREE_SHIPPING_THRESHOLD, TOTAL_DAYS_DE } from "../lib/shipping";
|
||||
import { formatPrice } from "../lib/products";
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: "/icon-trust-shipping.png",
|
||||
title: "Schneller Versand",
|
||||
desc: `In ${TOTAL_DAYS_DE.min}–${TOTAL_DAYS_DE.max} Werktagen bei dir.`,
|
||||
},
|
||||
{
|
||||
icon: "/icon-trust-free-shipping.png",
|
||||
title: "Versandkostenfrei",
|
||||
desc: `Ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} Bestellwert innerhalb DE.`,
|
||||
},
|
||||
{ icon: "/icon-trust-heart.png", title: "Mit Liebe verpackt", desc: "Für mehr Freude beim Auspacken." },
|
||||
];
|
||||
|
||||
export function TrustRow() {
|
||||
return (
|
||||
<div className="w-full bg-bg-base flex flex-col md:flex-row gap-6 md:gap-12 items-center justify-center py-8 px-[var(--layout-padding-x)]">
|
||||
{items.map((item, i) => (
|
||||
<div key={item.title} className="flex items-center gap-6 md:gap-12">
|
||||
{i > 0 && <div className="hidden md:block h-10 w-px bg-border" />}
|
||||
<div className="flex gap-4 items-center">
|
||||
<img alt="" src={item.icon} className="size-8 shrink-0 object-contain" />
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="font-semibold text-body text-text-primary whitespace-nowrap">{item.title}</p>
|
||||
<p className="text-body-sm text-text-muted whitespace-nowrap">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { VersandSections } from "../versand/components/VersandSections";
|
||||
|
||||
/**
|
||||
* Quick-reference version of /versand, opened from the cart's order
|
||||
* summary "Versand" info link — a full page navigation would pull you out
|
||||
* of checkout, which is exactly what the link is there to avoid. Reuses
|
||||
* VersandSections so the two never carry different numbers/copy.
|
||||
*/
|
||||
export function VersandModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
closeButtonRef.current?.focus();
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const focusables = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled])'
|
||||
);
|
||||
if (!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);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 md:p-8 bg-[rgba(134,134,134,0.9)]"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="versand-modal-heading"
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative bg-bg-base rounded-md overflow-hidden w-full max-w-[40rem] max-h-[85vh] overflow-y-auto"
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-8 py-6 bg-bg-base border-b border-border">
|
||||
<p
|
||||
id="versand-modal-heading"
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Versand
|
||||
</p>
|
||||
<button
|
||||
ref={closeButtonRef}
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="size-6 flex items-center justify-center active:scale-90 transition-transform text-text-muted hover:text-text-primary"
|
||||
>
|
||||
<span aria-hidden className="text-2xl leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-6 pb-8">
|
||||
<VersandSections />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
--color-checkout-active: #f6a701;
|
||||
--color-checkout-done: #1a1a18;
|
||||
--color-toc-active-border: #f6a701;
|
||||
--color-success: #2f8f4e;
|
||||
--color-success-subtle: #e8f4ea;
|
||||
|
||||
/* Radius */
|
||||
--radius-xs: 0.25rem;
|
||||
@@ -58,7 +60,9 @@
|
||||
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). */
|
||||
etc.)" per styleguide.md §2.2 — a different, smaller role). Also used
|
||||
by page-weekly-impulses's hero heading — same 48px role, reused
|
||||
rather than minting a near-duplicate token. */
|
||||
--text-h-page: clamp(2.5rem, 1.9286rem + 1.1905vw, 3rem);
|
||||
--text-h-page--line-height: 1.15;
|
||||
--text-h1: clamp(1.75rem, 1.1786rem + 1.1905vw, 2.25rem);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -52,11 +53,13 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased`}
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased scroll-smooth`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<Navbar />
|
||||
{children}
|
||||
<CartFlyProvider>
|
||||
<Navbar />
|
||||
{children}
|
||||
</CartFlyProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const CART_KEY = "ep_cart";
|
||||
const CART_EVENT = "ep-cart-updated";
|
||||
const EMPTY_CART: CartItem[] = [];
|
||||
|
||||
type CartItem = { id: string; qty: number };
|
||||
export type CartItem = { id: string; qty: number };
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
@@ -34,23 +35,66 @@ export function addToCart(id: string, qty = 1) {
|
||||
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;
|
||||
export function removeFromCart(id: string) {
|
||||
writeCart(readCart().filter((i) => i.id !== id));
|
||||
}
|
||||
|
||||
// qty <= 0 removes the item outright — the cart page's quantity stepper
|
||||
// never lets the visible count go below 1, but this keeps the function
|
||||
// itself safe to call with any integer without a separate remove path.
|
||||
export function setQuantity(id: string, qty: number) {
|
||||
if (qty <= 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
const items = readCart();
|
||||
const existing = items.find((i) => i.id === id);
|
||||
if (existing) existing.qty = qty;
|
||||
writeCart(items);
|
||||
}
|
||||
|
||||
// Cached-by-raw-string snapshot, not a fresh JSON.parse() every call —
|
||||
// useSyncExternalStore (below) requires getSnapshot to return the SAME
|
||||
// reference when the underlying data hasn't actually changed, or React
|
||||
// treats every render as a change. readCart()'s plain JSON.parse would
|
||||
// allocate a new array each call and break that.
|
||||
let cachedRaw: string | null | undefined;
|
||||
let cachedItems: CartItem[] = EMPTY_CART;
|
||||
|
||||
export function getCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return EMPTY_CART;
|
||||
const raw = window.localStorage.getItem(CART_KEY);
|
||||
if (raw === cachedRaw) return cachedItems;
|
||||
cachedRaw = raw;
|
||||
try {
|
||||
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
} catch {
|
||||
cachedItems = EMPTY_CART;
|
||||
}
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void) {
|
||||
window.addEventListener(CART_EVENT, onStoreChange);
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, onStoreChange);
|
||||
window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
}
|
||||
|
||||
// useSyncExternalStore, not useState+useEffect — localStorage is an
|
||||
// external store outside React, and the previous approach (setState
|
||||
// synchronously inside an effect body) causes an extra render and trips
|
||||
// the react-hooks/set-state-in-effect lint rule. This is React's own
|
||||
// recommended pattern for subscribing to exactly this kind of external
|
||||
// store, and is correctly SSR-safe via the third (server snapshot)
|
||||
// argument — 0 / EMPTY_CART until the client hydrates and reads
|
||||
// localStorage for real.
|
||||
export function useCartCount(): number {
|
||||
return useSyncExternalStore(subscribe, getCartCount, () => 0);
|
||||
}
|
||||
|
||||
export function useCart(): CartItem[] {
|
||||
return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Small in-code product catalog — this project has no commerce backend,
|
||||
// so cart items (stored in localStorage as {id, qty} pairs, see cart.ts)
|
||||
// need somewhere to look up name/price/photo/description by id. Only
|
||||
// "todo-karten" has a real detail page (/todo-cards) right now; the
|
||||
// other four exist because they're shown in Figma's page-cart "Passt
|
||||
// perfekt dazu" row and are addable to the cart from there, same as the
|
||||
// real product — they just don't have their own detail pages built yet
|
||||
// (matching the project's established pattern of cross-linking to
|
||||
// not-yet-built routes rather than leaving buttons disconnected).
|
||||
export type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
image: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const PRODUCTS: Record<string, Product> = {
|
||||
"todo-karten": {
|
||||
id: "todo-karten",
|
||||
name: "ToDo-Karten – Set",
|
||||
description: "50 ToDo-Karten für mehr Fokus und Klarheit im Alltag.",
|
||||
price: 12.9,
|
||||
image: "/product-todo-karten.png",
|
||||
href: "/todo-cards",
|
||||
},
|
||||
"notizbuch-klarheit": {
|
||||
id: "notizbuch-klarheit",
|
||||
name: "Notizbuch – Klarheit",
|
||||
description: "Dein Begleiter für Gedanken, Notizen und neue Perspektiven.",
|
||||
price: 9.9,
|
||||
image: "/product-notizbuch-klarheit.png",
|
||||
},
|
||||
wochenplaner: {
|
||||
id: "wochenplaner",
|
||||
name: "Wochenplaner – Überblick",
|
||||
description: "Behalte deine Woche im Blick und setze klare Prioritäten.",
|
||||
price: 14.9,
|
||||
image: "/product-wochenplaner.png",
|
||||
},
|
||||
"notizbuch-fokus": {
|
||||
id: "notizbuch-fokus",
|
||||
name: "Notizbuch – Fokus",
|
||||
description: "Für mehr Konzentration und einen klaren Kopf im Alltag.",
|
||||
price: 9.9,
|
||||
image: "/product-notizbuch-fokus.png",
|
||||
},
|
||||
zielkarten: {
|
||||
id: "zielkarten",
|
||||
name: "Zielkarten – Set",
|
||||
description: "Definiere deine Ziele und behalte sie fest im Blick.",
|
||||
price: 11.9,
|
||||
image: "/product-zielkarten.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const RELATED_PRODUCT_IDS = ["wochenplaner", "notizbuch-fokus", "zielkarten"];
|
||||
|
||||
// Shop overview grid order — mirrors page-shop-overview in Figma.
|
||||
export const SHOP_PRODUCT_IDS = ["todo-karten", "wochenplaner", "notizbuch-fokus", "zielkarten"];
|
||||
|
||||
export function formatPrice(value: number): string {
|
||||
return `${value.toFixed(2).replace(".", ",")} €`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Single source of truth for shipping numbers/timeframes — consumed by
|
||||
// both the cart's order summary (CartContent.tsx) and the /versand policy
|
||||
// page, so the two can never drift apart.
|
||||
export const SHIPPING_COST = 2.9;
|
||||
export const FREE_SHIPPING_THRESHOLD = 39;
|
||||
|
||||
// Kept as an explicit range (not "so schnell wie möglich") per Art. 246a
|
||||
// § 1 Abs. 1 Nr. 8 EGBGB — German law requires disclosing a concrete
|
||||
// delivery timeframe before contract conclusion, and split into
|
||||
// Bearbeitungszeit/Versanddauer so it's clear whether processing time is
|
||||
// included in the stated number, not just a single ambiguous figure.
|
||||
export const HANDLING_DAYS = { min: 1, max: 2 };
|
||||
export const TRANSIT_DAYS_DE = { min: 2, max: 4 };
|
||||
export const TOTAL_DAYS_DE = {
|
||||
min: HANDLING_DAYS.min + TRANSIT_DAYS_DE.min,
|
||||
max: HANDLING_DAYS.max + TRANSIT_DAYS_DE.max,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Fragment } from "react";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: "/icon-weekly-step-1.png",
|
||||
title: "1. Anmelden",
|
||||
desc: "Trage dich mit deiner E-Mail-Adresse ein und bestätige kurz.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-weekly-step-2.png",
|
||||
title: "2. E-Mail erhalten",
|
||||
desc: "Jeden Mittwoch bekommst du deinen neuen Impuls direkt in dein Postfach.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-weekly-step-3.png",
|
||||
title: "3. Lesen & umsetzen",
|
||||
desc: "Lies in wenigen Minuten und setze die Tipps direkt in deinem Alltag um.",
|
||||
},
|
||||
{
|
||||
icon: "/icon-weekly-step-4.png",
|
||||
title: "4. Dranbleiben",
|
||||
desc: "Kleine Schritte, jede Woche – für mehr Klarheit, Fokus und Leichtigkeit.",
|
||||
},
|
||||
];
|
||||
|
||||
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 funktioniert's
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Wöchentliche Inspiration. Kein Aufwand für dich.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* Same pattern as /todo-cards's HowItWorks: arrows always visible
|
||||
(rotated to point down while stacked below md:), RevealGroup/
|
||||
RevealItem stagger the steps in. lg:px-[10rem] mirrors Figma's
|
||||
Desktop-only px-[160px] inset (no fluid token — pure aesthetic
|
||||
narrowing, fine to just drop below lg:). */}
|
||||
<RevealGroup className="flex flex-col md:flex-row gap-8 items-center md:items-start w-full lg:px-[10rem]">
|
||||
{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">
|
||||
<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,74 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
"„Die wöchentlichen Impulse sind mein fester Start in die Woche. Kurz, hilfreich und immer genau richtig.“",
|
||||
name: "Sarah M.",
|
||||
role: "Marketing Managerin",
|
||||
avatar: "/avatar-weekly-1.png",
|
||||
},
|
||||
{
|
||||
quote: "„Endlich mal Tipps, die man wirklich umsetzen kann. Jeden Mittwoch freue ich mich auf die Mail.“",
|
||||
name: "Thomas K.",
|
||||
role: "Selbstständiger Berater",
|
||||
avatar: "/avatar-weekly-2.png",
|
||||
},
|
||||
{
|
||||
quote: "„Die Impulse helfen mir, den Fokus zu behalten und das Wesentliche nicht aus den Augen zu verlieren.“",
|
||||
name: "Miriam L.",
|
||||
role: "Projektleiterin",
|
||||
avatar: "/avatar-weekly-3.png",
|
||||
},
|
||||
];
|
||||
|
||||
// Same card style as /challenge and /todo-cards's testimonials (bg-muted,
|
||||
// no border, decorative quote-mark, quote on top with flex-1,
|
||||
// avatar+name+role row pinned to the bottom, hover-lift + avatar scale) —
|
||||
// this page's own Figma spec actually used a different bordered pattern,
|
||||
// but site-wide consistency across all three testimonial sections was
|
||||
// explicitly requested over per-page Figma fidelity here. No pagination
|
||||
// dots either, for the same reason — neither of the other two pages has
|
||||
// them. max-w-[1600px] matches Challenge's testimonial container cap —
|
||||
// see the identical comment in /todo-cards's Testimonials.tsx.
|
||||
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)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
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 relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{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>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const benefits = [
|
||||
{ title: "Praktische Impulse", desc: "Konkrete Ideen, die du sofort umsetzen kannst." },
|
||||
{ title: "Neue Perspektiven", desc: "Gedankenanstöße, die dir helfen, klarer zu sehen." },
|
||||
{ title: "Bewährte Methoden", desc: "Einfache Strategien für mehr Fokus und Struktur." },
|
||||
{ title: "Motivation & Erinnerung", desc: "Ein freundlicher Schub in die richtige Richtung." },
|
||||
];
|
||||
|
||||
export function WeeklyBenefits() {
|
||||
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="/weekly-photo.png"
|
||||
alt="Notizbuch mit wöchentlichem Impuls"
|
||||
fill
|
||||
sizes="(min-width: 1024px) 42vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="flex flex-col gap-6 items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-emphasis text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Das bekommst du jede Woche
|
||||
</p>
|
||||
<ul className="flex flex-col gap-4 items-start w-full">
|
||||
{benefits.map((b) => (
|
||||
<li key={b.title} className="flex gap-[0.625rem] items-start w-full">
|
||||
<img alt="" src="/icon-check.svg" className="size-5 shrink-0 mt-0.5" />
|
||||
<div className="flex flex-col gap-0.5 items-start flex-1 min-w-0">
|
||||
<p className="font-semibold text-body text-text-primary">{b.title}</p>
|
||||
<p className="text-body-sm text-text-muted">{b.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const checklist = [
|
||||
"Jeden Mittwoch neue Impulse & Tipps",
|
||||
"Kurz & knackig – in 5 Minuten gelesen",
|
||||
"Sofort umsetzbar für mehr Fokus und Struktur",
|
||||
"Kostenlos und jederzeit abbestellbar",
|
||||
];
|
||||
|
||||
export function WeeklyImpulsesHero() {
|
||||
return (
|
||||
<section className="bg-bg-base w-full overflow-hidden">
|
||||
{/* Same lg:-only structural exception as Home/todo-cards Hero (see
|
||||
figma-to-nextjs skill Gotcha #5) — a wide fixed-ratio photo next
|
||||
to a text column gets too cramped at Tablet widths under the
|
||||
site-wide md: convention. lg:items-center removed — see the
|
||||
breadcrumb/centering comment below, same fix as /todo-cards. */}
|
||||
{/* pt-10 md:pt-12, no lg override — same fix and same reasoning as
|
||||
/todo-cards's Hero: this is now the only thing positioning the
|
||||
breadcrumb, and it must match /todo-cards's and /challenge's
|
||||
value exactly for the breadcrumb to land at the same Y everywhere. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
{/* Breadcrumb — 3 levels here (Startseite › Werkzeuge › Impulse &
|
||||
Tipps), matching this page's actual Figma breadcrumb.
|
||||
Deliberately NOT part of the centered block below — see its
|
||||
comment. */}
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Impulse & Tipps</span>
|
||||
</p>
|
||||
|
||||
{/* Everything else — centered in the remaining vertical space at
|
||||
lg+, same fix as /todo-cards's Hero: keeps the breadcrumb
|
||||
above at a fixed, cross-page-consistent Y position instead of
|
||||
its offset drifting with this page's larger content (pill
|
||||
badge + 4 checklist items vs. /todo-cards's 3, no badge). */}
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
{/* Pill badge */}
|
||||
<div className="flex gap-2 items-center border border-border rounded-full pl-3.5 pr-4 py-2">
|
||||
<img alt="" src="/icon-envelope-small.svg" className="w-4 h-3 shrink-0" />
|
||||
<span className="text-body-sm font-semibold text-text-primary whitespace-nowrap">
|
||||
Jeden Mittwoch in deinem Postfach
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Heading — 48px Desktop, same new --text-h-page token as
|
||||
/todo-cards's hero heading (see globals.css). */}
|
||||
<p
|
||||
className="font-semibold text-h-page text-text-primary leading-[1.1]"
|
||||
style={{ fontFamily: "var(--font-playfair)" }}
|
||||
>
|
||||
Impulse & Tipps.
|
||||
<br />
|
||||
Wöchentliche Klarheit
|
||||
<br />
|
||||
für deinen Alltag.
|
||||
</p>
|
||||
|
||||
<p className="text-body text-text-body">
|
||||
Einmal pro Woche bekommst du konkrete Impulse, die dich wirklich weiterbringen – kurz, praktisch und direkt umsetzbar.
|
||||
</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>
|
||||
|
||||
{/* Inline email capture — page-specific, simpler than the shared
|
||||
Newsletter component's panel form (no button-adjacent styling
|
||||
needed here, just input + submit inline). */}
|
||||
<div className="flex gap-3 items-start w-full sm:w-auto">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Deine E-Mail-Adresse"
|
||||
className="w-full sm:w-[17.5rem] bg-bg-base border border-border rounded-sm px-4 py-[0.8125rem] text-body-sm text-text-muted font-normal outline-none focus:border-brand transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 bg-brand rounded-sm px-6 py-[0.8125rem] font-bold text-body text-text-primary whitespace-nowrap hover:bg-brand-hover active:scale-[0.97] transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[0.375rem] items-center">
|
||||
<img alt="" src="/icon-lock.svg" className="size-3.5" />
|
||||
<span className="text-label text-text-muted">Keine Werbung. Jederzeit abbestellbar.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
className="order-2 lg:order-none lg:col-span-7 group relative w-full aspect-[830/611] rounded-md overflow-hidden"
|
||||
delay={0.15}
|
||||
>
|
||||
<Image
|
||||
src="/hero-weekly-impulses.png"
|
||||
alt="Notizbuch mit wöchentlichem Impuls, Kaffee und Stift"
|
||||
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,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import { WeeklyImpulsesHero } from "./components/WeeklyImpulsesHero";
|
||||
import { HowItWorks } from "./components/HowItWorks";
|
||||
import { WeeklyBenefits } from "./components/WeeklyBenefits";
|
||||
import { Testimonials } from "./components/Testimonials";
|
||||
import { Newsletter } from "../components/Newsletter";
|
||||
import { Footer } from "../components/Footer";
|
||||
|
||||
const title = "Impulse & Tipps – Wöchentliche Klarheit für deinen Alltag";
|
||||
const description =
|
||||
"Einmal pro Woche bekommst du konkrete Impulse, die dich wirklich weiterbringen – kurz, praktisch und direkt umsetzbar. Kostenlos, jeden Mittwoch, jederzeit abbestellbar.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: "/newsletter",
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: "/newsletter",
|
||||
images: ["/hero-weekly-impulses.png"],
|
||||
},
|
||||
twitter: {
|
||||
title,
|
||||
description,
|
||||
images: ["/hero-weekly-impulses.png"],
|
||||
},
|
||||
};
|
||||
|
||||
// Route is "/newsletter" — not "/impulse-tipps" or similar — because that
|
||||
// path already existed as a dangling link from two places (Navbar's
|
||||
// "Newsletter" CTA button and Tools.tsx's "Impulse & Tipps" → "Anmelden"
|
||||
// card). Both point at the same underlying thing (signing up for the
|
||||
// weekly newsletter), so this page resolves both at once instead of
|
||||
// picking a new URL and leaving one of them still dangling.
|
||||
export default function NewsletterPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1">
|
||||
<WeeklyImpulsesHero />
|
||||
<HowItWorks />
|
||||
<WeeklyBenefits />
|
||||
<Testimonials />
|
||||
<Newsletter
|
||||
title="Wöchentliche Impulse für mehr Klarheit"
|
||||
description="Melde dich jetzt an und erhalte jeden Mittwoch neue Impulse & Tipps direkt in dein Postfach."
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import { Hero } from "./components/Hero";
|
||||
import { Divider } from "./components/Divider";
|
||||
import { Tools } from "./components/Tools";
|
||||
import { ProductSpotlight } from "./components/ProductSpotlight";
|
||||
import { Blog } from "./components/Blog";
|
||||
import { About } from "./components/About";
|
||||
import { Newsletter } from "./components/Newsletter";
|
||||
@@ -37,6 +38,7 @@ export default function Home() {
|
||||
<Hero />
|
||||
<Divider />
|
||||
<Tools />
|
||||
<ProductSpotlight />
|
||||
<Blog />
|
||||
<About />
|
||||
<Newsletter />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { PRODUCTS, SHOP_PRODUCT_IDS, formatPrice } from "../../lib/products";
|
||||
import { RevealGroup, RevealItem } from "../../components/Reveal";
|
||||
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
|
||||
|
||||
export function ProductGrid() {
|
||||
return (
|
||||
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">
|
||||
<RevealGroup className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full">
|
||||
{SHOP_PRODUCT_IDS.map((id) => {
|
||||
const product = PRODUCTS[id];
|
||||
return (
|
||||
<RevealItem
|
||||
key={id}
|
||||
className="group md:col-span-3 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 25vw, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
{product.href && (
|
||||
<Link
|
||||
href={product.href}
|
||||
className="flex items-center gap-1 font-bold text-label text-brand hover:underline"
|
||||
>
|
||||
<span>Mehr erfahren</span>
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* flex-1 spacer — pins every card's button to the same Y
|
||||
regardless of the "Mehr erfahren" line only the
|
||||
todo-karten card has (see figma-to-nextjs skill's
|
||||
Tools.tsx equal-height lesson). */}
|
||||
<div className="flex-1" />
|
||||
|
||||
<AddToCartInlineButton id={id} />
|
||||
</div>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
</RevealGroup>
|
||||
|
||||
<p className="text-label text-text-muted pt-6">Alle Preise inkl. MwSt., zzgl. Versandkosten.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
export function ShopHeader() {
|
||||
return (
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Shop</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Shop
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Alles, was du für mehr Klarheit im Alltag brauchst.
|
||||
</p>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ShopHeader } from "./components/ShopHeader";
|
||||
import { ProductGrid } from "./components/ProductGrid";
|
||||
import { TrustRow } from "../components/TrustRow";
|
||||
import { Footer } from "../components/Footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Shop",
|
||||
description:
|
||||
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
|
||||
alternates: { canonical: "/shop" },
|
||||
openGraph: {
|
||||
title: "Shop | einfach produktiv",
|
||||
description:
|
||||
"Alles, was du für mehr Klarheit im Alltag brauchst — ToDo-Karten, Wochenplaner, Notizbücher und Zielkarten von einfach produktiv.",
|
||||
url: "/shop",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShopPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<ShopHeader />
|
||||
<ProductGrid />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
import { TOTAL_DAYS_DE } from "../../lib/shipping";
|
||||
|
||||
const bullets = [
|
||||
"50 ToDo-Karten",
|
||||
@@ -49,6 +50,9 @@ export function Pricing() {
|
||||
<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>
|
||||
<p className="text-label text-text-muted">
|
||||
Lieferzeit: {TOTAL_DAYS_DE.min}–{TOTAL_DAYS_DE.max} Werktage innerhalb Deutschlands
|
||||
</p>
|
||||
<AddToCartButton
|
||||
label="In den Warenkorb"
|
||||
className="w-full inline-flex items-center justify-center px-6 py-[0.8125rem] rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-muted"
|
||||
|
||||
@@ -23,16 +23,23 @@ const testimonials = [
|
||||
},
|
||||
];
|
||||
|
||||
// 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).
|
||||
// Card layout/style matches app/challenge/page.tsx's and
|
||||
// app/newsletter/'s testimonial sections exactly (bg-muted filled card,
|
||||
// no border, decorative quote-mark, quote on top with flex-1 pushing
|
||||
// the avatar/name row to the bottom, hover-lift + avatar-scale) — all
|
||||
// three kept in sync deliberately as one shared visual pattern, overriding
|
||||
// each page's own (differing) Figma spec for this one section. Only the
|
||||
// photos differ per page (each has its own Figma-sourced avatars).
|
||||
// max-w-[1600px] on the inner wrapper matches Challenge's testimonial
|
||||
// container cap — without it this section's fluid px-[layout-padding-x]
|
||||
// alone has no upper bound, so cards kept growing wider than Challenge's
|
||||
// past ~1440px viewports (capped at 1280px there) and read as
|
||||
// inconsistently narrower on Challenge. 1600px is a deliberate compromise
|
||||
// between the two, not either page's original value.
|
||||
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)]">
|
||||
<div className="max-w-[1600px] mx-auto w-full flex flex-col gap-8 items-center">
|
||||
<Reveal
|
||||
className="font-semibold text-h-emphasis text-text-primary text-center"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
@@ -44,9 +51,15 @@ export function Testimonials() {
|
||||
{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"
|
||||
className="group relative md:col-span-4 bg-bg-muted rounded-xl p-6 flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6]">{t.quote}</p>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute top-4 right-6 font-bold text-[2.5rem] text-[#ccc] leading-none select-none"
|
||||
>
|
||||
”
|
||||
</span>
|
||||
<p className="flex-1 text-body-sm text-text-primary leading-[1.6] pr-8">{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" />
|
||||
@@ -59,6 +72,7 @@ export function Testimonials() {
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { AddToCartButton } from "./AddToCartButton";
|
||||
import { AddToCartButton } from "../../components/AddToCartButton";
|
||||
import { Reveal } from "../../components/Reveal";
|
||||
|
||||
const checklist = [
|
||||
@@ -15,11 +15,27 @@ export function TodoKartenHero() {
|
||||
{/* 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">
|
||||
Tablet widths under the site-wide md: convention. lg:items-center
|
||||
removed (default grid align-items is stretch) — that's what lets
|
||||
the breadcrumb below sit at a fixed Y position across every hero
|
||||
section on the site instead of shifting per page depending on how
|
||||
much content each one has above the fold. */}
|
||||
{/* pt-10 md:pt-12, no lg override — with lg:items-center gone, this
|
||||
padding is now the ONLY thing positioning the breadcrumb
|
||||
vertically at every tier (previously lg:pt-0 relied on centering
|
||||
to add its own implicit offset instead). Kept flat past md so it
|
||||
matches /newsletter's and /challenge's identical top offset. */}
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-12 gap-8 lg:gap-[var(--layout-grid-gap)] pt-10 md:pt-12">
|
||||
<Reveal className="order-1 lg:order-none lg:col-span-5 flex flex-col gap-6 items-start pl-[var(--layout-padding-x)] pr-10 lg:pr-0">
|
||||
{/* Breadcrumb */}
|
||||
{/* Breadcrumb — 3 levels (Startseite › Werkzeuge › ToDo-Karten),
|
||||
matching /newsletter's pattern; was missing "Startseite" as
|
||||
the first crumb. Deliberately NOT part of the centered block
|
||||
below — see the wrapper's comment. */}
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">
|
||||
Startseite
|
||||
</Link>
|
||||
<span>›</span>
|
||||
<Link href="/#werkzeuge" className="hover:text-brand transition-colors">
|
||||
Werkzeuge
|
||||
</Link>
|
||||
@@ -27,37 +43,46 @@ export function TodoKartenHero() {
|
||||
<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.
|
||||
{/* Everything else — centered in the remaining vertical space at
|
||||
lg+ (the column now stretches to match the image's height,
|
||||
since lg:items-center was removed above; flex-1 + justify-center
|
||||
here centers this block within that leftover space instead of
|
||||
the breadcrumb-inclusive whole column, which is what made the
|
||||
breadcrumb's Y position drift per page depending on total
|
||||
content height). */}
|
||||
<div className="flex flex-col gap-6 items-start w-full flex-1 lg:justify-center">
|
||||
{/* 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" />
|
||||
</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
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
SHIPPING_COST,
|
||||
FREE_SHIPPING_THRESHOLD,
|
||||
HANDLING_DAYS,
|
||||
TRANSIT_DAYS_DE,
|
||||
TOTAL_DAYS_DE,
|
||||
} from "../../lib/shipping";
|
||||
import { formatPrice } from "../../lib/products";
|
||||
|
||||
// Single source of truth for both the full /versand page and the cart's
|
||||
// quick-reference VersandModal — same section ids/titles/copy either way,
|
||||
// so the two can never drift apart. `withAnchors` adds scroll-margin (for
|
||||
// the full page's TOC) — the modal doesn't need it, it just scrolls its
|
||||
// own dialog body.
|
||||
export const VERSAND_SECTION_IDS = [
|
||||
{ id: "lieferzeiten", title: "Lieferzeiten" },
|
||||
{ id: "versandkosten", title: "Versandkosten" },
|
||||
{ id: "liefergebiet", title: "Liefergebiet" },
|
||||
{ id: "versanddienstleister", title: "Versanddienstleister" },
|
||||
{ id: "transportrisiko", title: "Transportrisiko" },
|
||||
{ id: "widerruf", title: "Rückgabe & Widerruf" },
|
||||
] as const;
|
||||
|
||||
function Section({
|
||||
id,
|
||||
title,
|
||||
withAnchor,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
withAnchor: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={"flex flex-col gap-3 items-start w-full" + (withAnchor ? " scroll-mt-32" : "")}
|
||||
>
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 items-start text-body text-text-body w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersandSections({ withAnchors = false }: { withAnchors?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 items-start w-full">
|
||||
<Section id="lieferzeiten" title="Lieferzeiten" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Nach Zahlungseingang bereiten wir deine Bestellung in der Regel innerhalb von{" "}
|
||||
{HANDLING_DAYS.min}–{HANDLING_DAYS.max} Werktagen zum Versand vor. Die Versanddauer
|
||||
innerhalb Deutschlands beträgt anschließend zusätzlich {TRANSIT_DAYS_DE.min}–
|
||||
{TRANSIT_DAYS_DE.max} Werktage.
|
||||
</p>
|
||||
<p>
|
||||
Damit ist deine Bestellung in der Regel nach {TOTAL_DAYS_DE.min}–{TOTAL_DAYS_DE.max}{" "}
|
||||
Werktagen bei dir, sofern auf der jeweiligen Produktseite nichts anderes angegeben ist.
|
||||
Als Werktage gelten Montag bis Freitag, ausgenommen gesetzliche Feiertage.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="versandkosten" title="Versandkosten" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Die Versandkosten innerhalb Deutschlands betragen pauschal {formatPrice(SHIPPING_COST)}{" "}
|
||||
pro Bestellung. Ab einem Bestellwert von {formatPrice(FREE_SHIPPING_THRESHOLD)} versenden
|
||||
wir kostenlos.
|
||||
</p>
|
||||
<p>Alle angegebenen Preise verstehen sich inklusive der gesetzlichen Mehrwertsteuer.</p>
|
||||
</Section>
|
||||
|
||||
<Section id="liefergebiet" title="Liefergebiet" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Aktuell versenden wir ausschließlich innerhalb Deutschlands. Eine Lieferung ins Ausland
|
||||
ist derzeit nicht möglich.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="versanddienstleister" title="Versanddienstleister" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Der Versand erfolgt über DHL. Sobald deine Bestellung das Lager verlässt, erhältst du
|
||||
eine E-Mail mit der Sendungsverfolgung.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="transportrisiko" title="Transportrisiko" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Bestellst du als Verbraucher:in, trägt das Transportrisiko bei Beschädigung oder Verlust
|
||||
während des Versands grundsätzlich einfach produktiv — nicht du.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="widerruf" title="Rückgabe & Widerruf" withAnchor={withAnchors}>
|
||||
<p>
|
||||
Informationen zu Rückgabe, Rücksendekosten und deinem gesetzlichen Widerrufsrecht findest
|
||||
du in unserer{" "}
|
||||
<Link href="/widerruf" className="text-brand hover:underline">
|
||||
Widerrufsbelehrung
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { VERSAND_SECTION_IDS } from "./VersandSections";
|
||||
|
||||
// lg:-only sidebar — same "wide fixed-width block next to content" shape
|
||||
// as the cart's order-summary sidebar (see figma-to-nextjs skill Gotcha
|
||||
// #5): a 360px TOC card plus a readable content column already exceeds
|
||||
// the 768px Tablet floor, so md: wouldn't leave room for a real 2-column
|
||||
// split at Tablet widths.
|
||||
export function VersandTOC() {
|
||||
const [active, setActive] = useState<string>(VERSAND_SECTION_IDS[0].id);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.filter((entry) => entry.isIntersecting);
|
||||
if (visible.length > 0) setActive(visible[0].target.id);
|
||||
},
|
||||
{ rootMargin: "-120px 0px -65% 0px", threshold: 0 }
|
||||
);
|
||||
VERSAND_SECTION_IDS.forEach(({ id }) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="hidden lg:flex flex-col gap-1 w-[22.5rem] shrink-0 bg-bg-base border border-border rounded-md p-6 sticky top-32 self-start">
|
||||
<p className="text-label font-semibold text-text-muted uppercase tracking-wide mb-2">
|
||||
Inhaltsverzeichnis
|
||||
</p>
|
||||
{VERSAND_SECTION_IDS.map(({ id, title }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={
|
||||
"px-3 py-2 rounded-sm text-body-sm transition-colors border-l-2 " +
|
||||
(active === id
|
||||
? "border-toc-active-border bg-bg-muted text-text-primary font-semibold"
|
||||
: "border-transparent text-text-muted hover:text-text-primary")
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { VersandSections } from "./components/VersandSections";
|
||||
import { VersandTOC } from "./components/VersandTOC";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Versand",
|
||||
description:
|
||||
"Versandkosten, Lieferzeiten und Liefergebiet für Bestellungen bei einfach produktiv.",
|
||||
alternates: { canonical: "/versand" },
|
||||
};
|
||||
|
||||
export default function VersandPage() {
|
||||
return (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Versand</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Versand
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
Transparent, fair und pünktlich — alles zu Kosten, Lieferzeiten und Liefergebiet.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<VersandTOC />
|
||||
<div className="w-full lg:flex-1 max-w-[45rem]">
|
||||
<VersandSections withAnchors />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="icon-envelope" clip-path="url(#clip0_0_82)">
|
||||
<path id="Vector" d="M13.7 0.8H2.3C1.47157 0.8 0.8 1.47157 0.8 2.3V9.7C0.8 10.5284 1.47157 11.2 2.3 11.2H13.7C14.5284 11.2 15.2 10.5284 15.2 9.7V2.3C15.2 1.47157 14.5284 0.8 13.7 0.8Z" stroke="var(--stroke-0, #F6A701)" stroke-width="1.3"/>
|
||||
<path id="Vector_2" d="M1.5 1.8L8 6.8L14.5 1.8" stroke="var(--stroke-0, #F6A701)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_82">
|
||||
<rect width="16" height="12" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 722 B |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 56 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="pagination-dots" clip-path="url(#clip0_0_68)">
|
||||
<circle id="dot" cx="4" cy="4" r="4" fill="var(--fill-0, #F6A701)"/>
|
||||
<circle id="dot_2" cx="20" cy="4" r="4" fill="var(--fill-0, #D9D9D9)"/>
|
||||
<circle id="dot_3" cx="36" cy="4" r="4" fill="var(--fill-0, #D9D9D9)"/>
|
||||
<circle id="dot_4" cx="52" cy="4" r="4" fill="var(--fill-0, #D9D9D9)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_68">
|
||||
<rect width="56" height="8" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 554 KiB |