Files
Marco 9e5532be63 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
2026-07-19 14:42:22 +00:00

96 lines
2.9 KiB
TypeScript

"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;
}