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:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user