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
+108
View File
@@ -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>
);
}