From 9e5532be63cb4c2e11d638171a4bc4a8802dae79 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 19 Jul 2026 14:42:22 +0000 Subject: [PATCH] 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 --- app/cart/components/CartContent.tsx | 237 ++++++++++++++++++ app/cart/components/RelatedProducts.tsx | 149 +++++++++++ app/cart/page.tsx | 30 +++ app/challenge/page.tsx | 114 ++++++--- app/components/AddToCartButton.tsx | 60 +++++ app/components/AddToCartInlineButton.tsx | 67 +++++ app/components/CartFly.tsx | 95 +++++++ app/components/Footer.tsx | 1 + app/components/Navbar.tsx | 108 ++++++-- app/components/Newsletter.tsx | 24 +- app/components/NewsletterModal.tsx | 211 ++++++++++++++++ app/components/ProductSpotlight.tsx | 64 +++++ app/components/TrustRow.tsx | 35 +++ app/components/VersandModal.tsx | 108 ++++++++ app/globals.css | 6 +- app/layout.tsx | 9 +- app/lib/cart.ts | 86 +++++-- app/lib/products.ts | 65 +++++ app/lib/shipping.ts | 17 ++ app/newsletter/components/HowItWorks.tsx | 73 ++++++ app/newsletter/components/Testimonials.tsx | 74 ++++++ app/newsletter/components/WeeklyBenefits.tsx | 45 ++++ .../components/WeeklyImpulsesHero.tsx | 122 +++++++++ app/newsletter/page.tsx | 54 ++++ app/page.tsx | 2 + app/shop/components/ProductGrid.tsx | 61 +++++ app/shop/components/ShopHeader.tsx | 23 ++ app/shop/page.tsx | 32 +++ app/todo-cards/components/AddToCartButton.tsx | 31 --- app/todo-cards/components/Pricing.tsx | 6 +- app/todo-cards/components/Testimonials.tsx | 32 ++- app/todo-cards/components/TodoKartenHero.tsx | 91 ++++--- app/versand/components/VersandSections.tsx | 113 +++++++++ app/versand/components/VersandTOC.tsx | 50 ++++ app/versand/page.tsx | 46 ++++ public/avatar-weekly-1.png | Bin 0 -> 19745 bytes public/avatar-weekly-2.png | Bin 0 -> 18208 bytes public/avatar-weekly-3.png | Bin 0 -> 20473 bytes public/hero-weekly-impulses.png | Bin 0 -> 1435121 bytes public/icon-cart-outline.png | Bin 0 -> 10514 bytes public/icon-checklist.png | Bin 0 -> 52555 bytes public/icon-close.png | Bin 0 -> 35637 bytes public/icon-envelope-hint.png | Bin 0 -> 22814 bytes public/icon-envelope-small.svg | 11 + public/icon-heart.png | Bin 0 -> 44461 bytes public/icon-sparkle-wrapper.svg | 5 + public/icon-trust-free-shipping.png | Bin 0 -> 32939 bytes public/icon-trust-heart.png | Bin 0 -> 22299 bytes public/icon-trust-leaf.png | Bin 0 -> 11635 bytes public/icon-trust-materials.png | Bin 0 -> 8511 bytes public/icon-trust-return.png | Bin 0 -> 7658 bytes public/icon-trust-shipping.png | Bin 0 -> 27772 bytes public/icon-weekly-step-1.png | Bin 0 -> 34658 bytes public/icon-weekly-step-2.png | Bin 0 -> 40518 bytes public/icon-weekly-step-3.png | Bin 0 -> 45226 bytes public/icon-weekly-step-4.png | Bin 0 -> 40871 bytes public/newsletter-modal-photo.jpg | Bin 0 -> 86442 bytes public/pagination-dots.svg | 13 + public/product-notizbuch-fokus.png | Bin 0 -> 383865 bytes public/product-notizbuch-klarheit.png | Bin 0 -> 137797 bytes public/product-wochenplaner.png | Bin 0 -> 336737 bytes public/product-zielkarten.png | Bin 0 -> 393536 bytes public/weekly-photo.png | Bin 0 -> 566905 bytes 63 files changed, 2205 insertions(+), 165 deletions(-) create mode 100644 app/cart/components/CartContent.tsx create mode 100644 app/cart/components/RelatedProducts.tsx create mode 100644 app/cart/page.tsx create mode 100644 app/components/AddToCartButton.tsx create mode 100644 app/components/AddToCartInlineButton.tsx create mode 100644 app/components/CartFly.tsx create mode 100644 app/components/NewsletterModal.tsx create mode 100644 app/components/ProductSpotlight.tsx create mode 100644 app/components/TrustRow.tsx create mode 100644 app/components/VersandModal.tsx create mode 100644 app/lib/products.ts create mode 100644 app/lib/shipping.ts create mode 100644 app/newsletter/components/HowItWorks.tsx create mode 100644 app/newsletter/components/Testimonials.tsx create mode 100644 app/newsletter/components/WeeklyBenefits.tsx create mode 100644 app/newsletter/components/WeeklyImpulsesHero.tsx create mode 100644 app/newsletter/page.tsx create mode 100644 app/shop/components/ProductGrid.tsx create mode 100644 app/shop/components/ShopHeader.tsx create mode 100644 app/shop/page.tsx delete mode 100644 app/todo-cards/components/AddToCartButton.tsx create mode 100644 app/versand/components/VersandSections.tsx create mode 100644 app/versand/components/VersandTOC.tsx create mode 100644 app/versand/page.tsx create mode 100644 public/avatar-weekly-1.png create mode 100644 public/avatar-weekly-2.png create mode 100644 public/avatar-weekly-3.png create mode 100644 public/hero-weekly-impulses.png create mode 100644 public/icon-cart-outline.png create mode 100644 public/icon-checklist.png create mode 100644 public/icon-close.png create mode 100644 public/icon-envelope-hint.png create mode 100644 public/icon-envelope-small.svg create mode 100644 public/icon-heart.png create mode 100644 public/icon-sparkle-wrapper.svg create mode 100644 public/icon-trust-free-shipping.png create mode 100644 public/icon-trust-heart.png create mode 100644 public/icon-trust-leaf.png create mode 100644 public/icon-trust-materials.png create mode 100644 public/icon-trust-return.png create mode 100644 public/icon-trust-shipping.png create mode 100644 public/icon-weekly-step-1.png create mode 100644 public/icon-weekly-step-2.png create mode 100644 public/icon-weekly-step-3.png create mode 100644 public/icon-weekly-step-4.png create mode 100644 public/newsletter-modal-photo.jpg create mode 100644 public/pagination-dots.svg create mode 100644 public/product-notizbuch-fokus.png create mode 100644 public/product-notizbuch-klarheit.png create mode 100644 public/product-wochenplaner.png create mode 100644 public/product-zielkarten.png create mode 100644 public/weekly-photo.png diff --git a/app/cart/components/CartContent.tsx b/app/cart/components/CartContent.tsx new file mode 100644 index 0000000..864492b --- /dev/null +++ b/app/cart/components/CartContent.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { useCart, removeFromCart, setQuantity } from "../../lib/cart"; +import { PRODUCTS, formatPrice } from "../../lib/products"; +import { SHIPPING_COST, FREE_SHIPPING_THRESHOLD } from "../../lib/shipping"; +import { Reveal } from "../../components/Reveal"; +import { VersandModal } from "../../components/VersandModal"; + +export function CartContent() { + const [versandOpen, setVersandOpen] = useState(false); + const cart = useCart(); + const items = cart + .map((entry) => ({ entry, product: PRODUCTS[entry.id] })) + .filter((row): row is { entry: typeof cart[number]; product: NonNullable<(typeof row)["product"]> } => Boolean(row.product)); + + const subtotal = items.reduce((sum, { entry, product }) => sum + entry.qty * product.price, 0); + const shipping = items.length === 0 || subtotal >= FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST; + const total = subtotal + shipping; + + return ( + <> + {/* Page header */} + +

+ Startseite + + Warenkorb +

+

+ Warenkorb +

+

+ {items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."} +

+
+ + {items.length === 0 ? ( + +

+ Schau dir unsere Produkte an und finde, was zu dir passt. +

+ + + Weiter einkaufen + +
+ ) : ( +
+ {/* Cart card — lg:-only split from the sidebar (same "wide content + next to sidebar" shape as the Hero's image/text split, see + figma-to-nextjs skill Gotcha #5: Figma's 830px card alone + already exceeds the 768px Tablet floor, so md: would never + have had room for a real 2-column layout at Tablet widths + anyway). */} + + {items.map(({ entry, product }, i) => ( +
+ {i > 0 &&
} +
+
+ {product.name} +
+
+

+ {product.name} +

+

{product.description}

+
+

Einzelpreis

+

+ {formatPrice(product.price)} + inkl. MwSt. +

+
+
+
+ + +

+ {formatPrice(entry.qty * product.price)} +

+ +
+
+
+ ))} + +
+ + + + Weiter einkaufen + + + + {/* Sidebar */} + +
+

+ Bestellübersicht +

+ +
+ Zwischensumme + + {formatPrice(subtotal)} +
+ +
+
+ + Versand + + + + + {shipping === 0 ? "Kostenlos" : formatPrice(shipping)} + +
+

+ {shipping === 0 + ? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands` + : "innerhalb Deutschlands"} +

+
+ +
+ +
+
+ + Gesamtsumme + + + {formatPrice(total)} +
+

inkl. MwSt.

+
+ + + Zur Kasse gehen + + +
+ + Sichere Zahlung +
+
+ +
+ {[ + { icon: "/icon-trust-leaf.png", text: "Nachhaltig produziert in Deutschland" }, + { icon: "/icon-trust-materials.png", text: "Hochwertige Materialien" }, + { icon: "/icon-trust-return.png", text: "14 Tage Rückgaberecht" }, + ].map((b) => ( +
+ + {b.text} +
+ ))} +
+ +
+ )} + + {items.length > 0 && ( + +
+
+ +
+
+
+

+ Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail. +

+

+ Für mehr Klarheit, Fokus und Struktur – jede Woche. +

+
+
+ + )} + + setVersandOpen(false)} /> + + ); +} diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx new file mode 100644 index 0000000..2c8f4c2 --- /dev/null +++ b/app/cart/components/RelatedProducts.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { PRODUCTS, RELATED_PRODUCT_IDS, formatPrice } from "../../lib/products"; +import { Reveal } from "../../components/Reveal"; +import { AddToCartInlineButton, FEEDBACK_MS } from "../../components/AddToCartInlineButton"; +import { useCart } from "../../lib/cart"; + +const ALL_PRODUCT_IDS = Object.keys(PRODUCTS); +const DISPLAY_COUNT = 3; + +function pickRandom(excludeIds: string[], count: number): string[] { + const pool = ALL_PRODUCT_IDS.filter((id) => !excludeIds.includes(id)); + const shuffled = [...pool].sort(() => Math.random() - 0.5); + return shuffled.slice(0, count); +} + +export function RelatedProducts() { + const cart = useCart(); + const hasItems = cart.length > 0; + const cartKey = cart + .map((i) => i.id) + .sort() + .join(","); + + // Math.random() can't run during the render that has to match the SSR + // pass (that would be a hydration mismatch — server and client would + // pick different products) — so the first paint always shows the fixed + // RELATED_PRODUCT_IDS fallback, and a client-only effect swaps in the + // real, cart-aware random pick right after mount. + const [displayIds, setDisplayIds] = useState(RELATED_PRODUCT_IDS); + + // First run (mount/hydration): pick the initial random set. After that, + // don't reshuffle everything on every cart change — one of these cards' + // own AddToCartInlineButton adds to the cart without navigating away, + // and a full reshuffle would yank the other two cards out from under + // the user mid-browse. Instead, only swap out whichever displayed card + // just became "already in the cart" (no longer relevant to recommend) + // and top that one slot back up — the rest stay exactly where they were. + const pickedRef = useRef(false); + const swapTimeoutRef = useRef | undefined>(undefined); + + useEffect(() => () => clearTimeout(swapTimeoutRef.current), []); + + useEffect(() => { + const cartIds = cartKey ? cartKey.split(",") : []; + + if (!pickedRef.current) { + pickedRef.current = true; + setDisplayIds(pickRandom(cartIds, DISPLAY_COUNT)); + return; + } + + // Delayed by FEEDBACK_MS, not immediate — the add usually came from + // clicking one of THESE cards' own AddToCartInlineButton, which shows + // a ~2s green "Hinzugefügt ✓" state. Swapping the card out right away + // unmounts that button before the confirmation is ever visible. + clearTimeout(swapTimeoutRef.current); + swapTimeoutRef.current = setTimeout(() => { + setDisplayIds((prev) => { + const stillRelevant = prev.filter((id) => !cartIds.includes(id)); + const missing = DISPLAY_COUNT - stillRelevant.length; + if (missing <= 0) return stillRelevant; + + let replacements = pickRandom([...cartIds, ...stillRelevant], missing); + + // The catalog only has 5 products — once the cart holds 3+ + // distinct ones, "3 recommendations that aren't already in the + // cart" becomes mathematically impossible (5 - 3 in cart leaves + // only 2 to show). Falling back to re-suggesting something + // already in the cart (a normal "grab another one" pattern) beats + // silently shrinking the grid below DISPLAY_COUNT. + if (replacements.length < missing) { + const stillMissing = missing - replacements.length; + const fallback = pickRandom([...stillRelevant, ...replacements], stillMissing); + replacements = [...replacements, ...fallback]; + } + + return [...stillRelevant, ...replacements]; + }); + }, FEEDBACK_MS); + }, [cartKey]); + + if (displayIds.length === 0) return null; + + return ( +
+ +

+ {hasItems ? "Passt perfekt dazu" : "Beliebte Produkte"} +

+

+ {hasItems ? "Weitere Tools für deinen klaren Alltag." : "Entdecke, was zu dir passt."} +

+
+ + {/* No separate price-disclosure footnote here — the single + "* inkl. MwSt., zzgl. Versandkosten" note lives directly under + the cart's own product table instead (CartContent.tsx), close + enough on the same page view to cover these cards too. + + Plain divs, not RevealGroup/RevealItem — this is the one grid on + the site whose items get swapped after the initial mount (see + the swap-in-place effect above). RevealItem has no viewport + trigger of its own; it only ever renders visible because it + inherits the parent RevealGroup's already-resolved "show" state + at first mount. A card swapped in later doesn't get a fresh + trigger and can end up stuck at its hidden variant + (opacity: 0) — invisible. Not worth chasing a fix for a + scroll-reveal nicety on a list that mutates; a static grid + renders correctly with no animation risk. */} +
+ {displayIds.map((id) => { + const product = PRODUCTS[id]; + return ( +
+
+ {product.name} +
+
+

+ {product.name} +

+

{formatPrice(product.price)}

+ +
+
+ ); + })} +
+
+ ); +} diff --git a/app/cart/page.tsx b/app/cart/page.tsx new file mode 100644 index 0000000..69fcbe0 --- /dev/null +++ b/app/cart/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import { CartContent } from "./components/CartContent"; +import { RelatedProducts } from "./components/RelatedProducts"; +import { TrustRow } from "../components/TrustRow"; +import { Footer } from "../components/Footer"; + +// robots: noindex — transactional page (mirrors a specific shopper's cart +// contents), per the figma-to-nextjs skill's Step 5 guidance: indexing +// this wastes crawl budget and could surface cart state in search results. +export const metadata: Metadata = { + title: "Warenkorb", + description: "Dein Warenkorb bei einfach produktiv.", + robots: { + index: false, + follow: true, + }, +}; + +export default function CartPage() { + return ( + <> +
+ + + +
+