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
This commit is contained in:
@@ -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" },
|
||||
];
|
||||
|
||||
+86
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user