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
+113
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+46
View File
@@ -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 />
</>
);
}