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
+65 -21
View File
@@ -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);
}
+65
View File
@@ -0,0 +1,65 @@
// Small in-code product catalog — this project has no commerce backend,
// so cart items (stored in localStorage as {id, qty} pairs, see cart.ts)
// need somewhere to look up name/price/photo/description by id. Only
// "todo-karten" has a real detail page (/todo-cards) right now; the
// other four exist because they're shown in Figma's page-cart "Passt
// perfekt dazu" row and are addable to the cart from there, same as the
// real product — they just don't have their own detail pages built yet
// (matching the project's established pattern of cross-linking to
// not-yet-built routes rather than leaving buttons disconnected).
export type Product = {
id: string;
name: string;
description: string;
price: number;
image: string;
href?: string;
};
export const PRODUCTS: Record<string, Product> = {
"todo-karten": {
id: "todo-karten",
name: "ToDo-Karten Set",
description: "50 ToDo-Karten für mehr Fokus und Klarheit im Alltag.",
price: 12.9,
image: "/product-todo-karten.png",
href: "/todo-cards",
},
"notizbuch-klarheit": {
id: "notizbuch-klarheit",
name: "Notizbuch Klarheit",
description: "Dein Begleiter für Gedanken, Notizen und neue Perspektiven.",
price: 9.9,
image: "/product-notizbuch-klarheit.png",
},
wochenplaner: {
id: "wochenplaner",
name: "Wochenplaner Überblick",
description: "Behalte deine Woche im Blick und setze klare Prioritäten.",
price: 14.9,
image: "/product-wochenplaner.png",
},
"notizbuch-fokus": {
id: "notizbuch-fokus",
name: "Notizbuch Fokus",
description: "Für mehr Konzentration und einen klaren Kopf im Alltag.",
price: 9.9,
image: "/product-notizbuch-fokus.png",
},
zielkarten: {
id: "zielkarten",
name: "Zielkarten Set",
description: "Definiere deine Ziele und behalte sie fest im Blick.",
price: 11.9,
image: "/product-zielkarten.png",
},
};
export const RELATED_PRODUCT_IDS = ["wochenplaner", "notizbuch-fokus", "zielkarten"];
// Shop overview grid order — mirrors page-shop-overview in Figma.
export const SHOP_PRODUCT_IDS = ["todo-karten", "wochenplaner", "notizbuch-fokus", "zielkarten"];
export function formatPrice(value: number): string {
return `${value.toFixed(2).replace(".", ",")}`;
}
+17
View File
@@ -0,0 +1,17 @@
// Single source of truth for shipping numbers/timeframes — consumed by
// both the cart's order summary (CartContent.tsx) and the /versand policy
// page, so the two can never drift apart.
export const SHIPPING_COST = 2.9;
export const FREE_SHIPPING_THRESHOLD = 39;
// Kept as an explicit range (not "so schnell wie möglich") per Art. 246a
// § 1 Abs. 1 Nr. 8 EGBGB — German law requires disclosing a concrete
// delivery timeframe before contract conclusion, and split into
// Bearbeitungszeit/Versanddauer so it's clear whether processing time is
// included in the stated number, not just a single ambiguous figure.
export const HANDLING_DAYS = { min: 1, max: 2 };
export const TRANSIT_DAYS_DE = { min: 2, max: 4 };
export const TOTAL_DAYS_DE = {
min: HANDLING_DAYS.min + TRANSIT_DAYS_DE.min,
max: HANDLING_DAYS.max + TRANSIT_DAYS_DE.max,
};