9e5532be63
- 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
96 lines
2.9 KiB
TypeScript
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;
|
|
}
|