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:
+65
-21
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const CART_KEY = "ep_cart";
|
||||
const CART_EVENT = "ep-cart-updated";
|
||||
const EMPTY_CART: CartItem[] = [];
|
||||
|
||||
type CartItem = { id: string; qty: number };
|
||||
export type CartItem = { id: string; qty: number };
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
@@ -34,23 +35,66 @@ export function addToCart(id: string, qty = 1) {
|
||||
writeCart(items);
|
||||
}
|
||||
|
||||
// Reactive cart item count — 0 until the client hydrates and reads
|
||||
// localStorage, then stays in sync across tabs ("storage") and same-tab
|
||||
// updates (the CART_EVENT dispatched by addToCart, which "storage" alone
|
||||
// doesn't fire for the tab that made the change).
|
||||
export function useCartCount(): number {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCount(getCartCount());
|
||||
const onUpdate = () => setCount(getCartCount());
|
||||
window.addEventListener(CART_EVENT, onUpdate);
|
||||
window.addEventListener("storage", onUpdate);
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, onUpdate);
|
||||
window.removeEventListener("storage", onUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return count;
|
||||
export function removeFromCart(id: string) {
|
||||
writeCart(readCart().filter((i) => i.id !== id));
|
||||
}
|
||||
|
||||
// qty <= 0 removes the item outright — the cart page's quantity stepper
|
||||
// never lets the visible count go below 1, but this keeps the function
|
||||
// itself safe to call with any integer without a separate remove path.
|
||||
export function setQuantity(id: string, qty: number) {
|
||||
if (qty <= 0) {
|
||||
removeFromCart(id);
|
||||
return;
|
||||
}
|
||||
const items = readCart();
|
||||
const existing = items.find((i) => i.id === id);
|
||||
if (existing) existing.qty = qty;
|
||||
writeCart(items);
|
||||
}
|
||||
|
||||
// Cached-by-raw-string snapshot, not a fresh JSON.parse() every call —
|
||||
// useSyncExternalStore (below) requires getSnapshot to return the SAME
|
||||
// reference when the underlying data hasn't actually changed, or React
|
||||
// treats every render as a change. readCart()'s plain JSON.parse would
|
||||
// allocate a new array each call and break that.
|
||||
let cachedRaw: string | null | undefined;
|
||||
let cachedItems: CartItem[] = EMPTY_CART;
|
||||
|
||||
export function getCart(): CartItem[] {
|
||||
if (typeof window === "undefined") return EMPTY_CART;
|
||||
const raw = window.localStorage.getItem(CART_KEY);
|
||||
if (raw === cachedRaw) return cachedItems;
|
||||
cachedRaw = raw;
|
||||
try {
|
||||
cachedItems = raw ? JSON.parse(raw) : EMPTY_CART;
|
||||
} catch {
|
||||
cachedItems = EMPTY_CART;
|
||||
}
|
||||
return cachedItems;
|
||||
}
|
||||
|
||||
function subscribe(onStoreChange: () => void) {
|
||||
window.addEventListener(CART_EVENT, onStoreChange);
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, onStoreChange);
|
||||
window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
}
|
||||
|
||||
// useSyncExternalStore, not useState+useEffect — localStorage is an
|
||||
// external store outside React, and the previous approach (setState
|
||||
// synchronously inside an effect body) causes an extra render and trips
|
||||
// the react-hooks/set-state-in-effect lint rule. This is React's own
|
||||
// recommended pattern for subscribing to exactly this kind of external
|
||||
// store, and is correctly SSR-safe via the third (server snapshot)
|
||||
// argument — 0 / EMPTY_CART until the client hydrates and reads
|
||||
// localStorage for real.
|
||||
export function useCartCount(): number {
|
||||
return useSyncExternalStore(subscribe, getCartCount, () => 0);
|
||||
}
|
||||
|
||||
export function useCart(): CartItem[] {
|
||||
return useSyncExternalStore(subscribe, getCart, () => EMPTY_CART);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user