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:
@@ -0,0 +1,95 @@
|
||||
"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;
|
||||
}
|
||||
Reference in New Issue
Block a user