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,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 */}
|
||||
<Reveal className="flex flex-col gap-3 items-start pb-6 pt-10 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="flex items-center gap-2 text-body-sm text-text-muted">
|
||||
<Link href="/" className="hover:text-brand transition-colors">Startseite</Link>
|
||||
<span>›</span>
|
||||
<span className="text-text-primary">Warenkorb</span>
|
||||
</p>
|
||||
<p
|
||||
className="font-semibold text-h-feature text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Warenkorb
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
{items.length > 0 ? "Schön, dass du da bist." : "Dein Warenkorb ist noch leer."}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Reveal className="flex flex-col gap-6 items-start pb-16 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
<p className="text-body text-text-body">
|
||||
Schau dir unsere Produkte an und finde, was zu dir passt.
|
||||
</p>
|
||||
<Link
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
) : (
|
||||
<div className="flex flex-col lg:flex-row gap-8 lg:gap-10 items-start pb-10 pt-2 px-[var(--layout-padding-x)] w-full">
|
||||
{/* 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). */}
|
||||
<Reveal className="w-full lg:flex-1 flex flex-col gap-6 items-start bg-bg-base border border-border rounded-md p-6 md:p-8">
|
||||
{items.map(({ entry, product }, i) => (
|
||||
<div key={product.id} className="w-full">
|
||||
{i > 0 && <div className="h-px bg-border w-full mb-6" />}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 items-start sm:items-center w-full">
|
||||
<div className="relative size-[9.375rem] shrink-0 rounded-sm overflow-hidden">
|
||||
<Image src={product.image} alt={product.name} fill sizes="150px" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-[0.625rem] items-start flex-1 min-w-0 w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-body-sm text-text-muted">{product.description}</p>
|
||||
<div className="flex flex-col gap-0.5 items-start">
|
||||
<p className="text-label text-text-muted">Einzelpreis</p>
|
||||
<p className="flex items-baseline gap-1.5">
|
||||
<span className="font-bold text-body-sm text-text-primary">{formatPrice(product.price)}</span>
|
||||
<span className="text-label text-text-muted">inkl. MwSt.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center shrink-0 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<label className="sr-only" htmlFor={`qty-${product.id}`}>
|
||||
Menge für {product.name}
|
||||
</label>
|
||||
<select
|
||||
id={`qty-${product.id}`}
|
||||
value={entry.qty}
|
||||
onChange={(e) => setQuantity(product.id, Number(e.target.value))}
|
||||
className="border border-border rounded-sm px-3.5 py-2 text-body-sm text-text-primary outline-none focus:border-brand transition-colors"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, n) => n + 1).map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-bold text-h4 text-text-primary whitespace-nowrap">
|
||||
{formatPrice(entry.qty * product.price)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromCart(product.id)}
|
||||
aria-label={`${product.name} entfernen`}
|
||||
className="text-text-muted hover:text-text-primary text-xl leading-none active:scale-90 transition-all"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<Link
|
||||
href="/shop"
|
||||
className="flex gap-2 items-center text-text-primary hover:text-brand transition-colors"
|
||||
>
|
||||
<span aria-hidden>←</span>
|
||||
<span className="font-bold text-body-sm">Weiter einkaufen</span>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Reveal delay={0.1} className="w-full lg:w-[20.625rem] lg:shrink-0 flex flex-col gap-6 items-start">
|
||||
<div className="bg-bg-base border border-border rounded-md p-7 flex flex-col gap-5 items-start w-full">
|
||||
<p
|
||||
className="font-semibold text-h-small text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Bestellübersicht
|
||||
</p>
|
||||
|
||||
<div className="flex items-center w-full">
|
||||
<span className="text-body-sm text-text-primary">Zwischensumme</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span className="flex items-center gap-1.5 text-body-sm text-text-primary">
|
||||
Versand
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVersandOpen(true)}
|
||||
className="text-text-muted hover:text-brand transition-colors"
|
||||
aria-label="Alle Informationen zu Versandkosten und Lieferzeiten"
|
||||
>
|
||||
<span aria-hidden>ⓘ</span>
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-body-sm text-text-primary">
|
||||
{shipping === 0 ? "Kostenlos" : formatPrice(shipping)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">
|
||||
{shipping === 0
|
||||
? `ab ${formatPrice(FREE_SHIPPING_THRESHOLD)} innerhalb Deutschlands`
|
||||
: "innerhalb Deutschlands"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border w-full" />
|
||||
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<div className="flex items-center w-full">
|
||||
<span
|
||||
className="font-semibold text-h4 text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
Gesamtsumme
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="font-bold text-h-small text-text-primary">{formatPrice(total)}</span>
|
||||
</div>
|
||||
<p className="text-label text-text-muted">inkl. MwSt.</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="w-full flex items-center justify-center py-4 rounded-sm bg-brand hover:bg-brand-hover active:scale-[0.97] transition-all font-bold text-body text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base"
|
||||
>
|
||||
Zur Kasse gehen
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-[0.625rem] items-center justify-center w-full">
|
||||
<img alt="" src="/icon-lock.svg" className="w-4 h-[1.125rem]" />
|
||||
<span className="text-body-sm text-text-muted">Sichere Zahlung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={b.text} className="flex gap-3 items-center w-full">
|
||||
<img alt="" src={b.icon} className="size-[1.375rem] shrink-0 object-contain" />
|
||||
<span className="flex-1 text-body-sm text-text-primary">{b.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<Reveal className="flex flex-col items-start pb-10 px-[var(--layout-padding-x)] w-full">
|
||||
<div className="bg-bg-muted flex gap-5 items-start p-6 rounded-md w-full lg:max-w-[51.875rem]">
|
||||
<div className="flex flex-col gap-3 items-center justify-center shrink-0">
|
||||
<img alt="" src="/icon-envelope-hint.png" className="h-[2.8125rem] w-16 object-contain" />
|
||||
<div className="h-[0.1875rem] w-6 bg-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-start flex-1 min-w-0 text-text-primary">
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Nach deiner Bestellung bekommst du regelmäßig Impulse & Tipps per E-Mail.
|
||||
</p>
|
||||
<p className="text-body-sm leading-[1.45]">
|
||||
Für mehr Klarheit, Fokus und Struktur – jede Woche.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<VersandModal open={versandOpen} onClose={() => setVersandOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>(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<ReturnType<typeof setTimeout> | 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 (
|
||||
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
|
||||
<Reveal className="flex flex-col gap-2 items-center text-center">
|
||||
<p
|
||||
className="font-semibold text-h-section text-text-primary"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{hasItems ? "Passt perfekt dazu" : "Beliebte Produkte"}
|
||||
</p>
|
||||
<p className="text-body text-text-muted">
|
||||
{hasItems ? "Weitere Tools für deinen klaren Alltag." : "Entdecke, was zu dir passt."}
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-[var(--layout-grid-gap)] w-full max-w-[75rem]">
|
||||
{displayIds.map((id) => {
|
||||
const product = PRODUCTS[id];
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="group md:col-span-4 bg-bg-base border border-border rounded-md overflow-hidden flex flex-col gap-4 transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<div className="relative w-full aspect-[320/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 768px) 320px, 100vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-2 w-full">
|
||||
<p
|
||||
className="font-semibold text-h4 text-text-primary w-full"
|
||||
style={{ fontFamily: "var(--font-lora)" }}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</p>
|
||||
<AddToCartInlineButton id={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
<CartContent />
|
||||
<RelatedProducts />
|
||||
<TrustRow />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user