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:
Marco
2026-07-19 14:42:22 +00:00
parent e4f0c66d7b
commit 9e5532be63
63 changed files with 2205 additions and 165 deletions
@@ -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>
);
}
+5 -1
View File
@@ -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 -9
View File
@@ -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>
);
}
+58 -33
View File
@@ -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