diff --git a/app/api/account/wishlist/route.ts b/app/api/account/wishlist/route.ts
new file mode 100644
index 0000000..ccc4552
--- /dev/null
+++ b/app/api/account/wishlist/route.ts
@@ -0,0 +1,26 @@
+import { NextResponse } from "next/server";
+import { getSessionCustomer } from "../../../lib/customerAuth";
+import { getWishlist, toggleWishlistItem } from "../../../lib/customerAuth";
+
+export async function GET() {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ items: [] }, { status: 401 });
+ const items = await getWishlist(session.token, session.customer.id);
+ return NextResponse.json({ items });
+}
+
+export async function POST(request: Request) {
+ const session = await getSessionCustomer();
+ if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
+
+ const body = await request.json().catch(() => null);
+ const productId = Number(body?.productId);
+ const variant = typeof body?.variant === "string" ? body.variant : "";
+ if (!Number.isInteger(productId) || productId <= 0) {
+ return NextResponse.json({ ok: false, reason: "Ungültiges Produkt." }, { status: 400 });
+ }
+
+ const result = await toggleWishlistItem(session.token, productId, variant);
+ if (!result.ok) return NextResponse.json({ ok: false, reason: "Merkliste konnte nicht aktualisiert werden." }, { status: 500 });
+ return NextResponse.json({ ok: true, wishlisted: result.wishlisted });
+}
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
index fdcb474..d24292c 100644
--- a/app/components/Navbar.tsx
+++ b/app/components/Navbar.tsx
@@ -6,6 +6,7 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { useCartCount } from "../lib/cart";
+import { useWishlist } from "../lib/useWishlist";
import { AUTH_CHANGED_EVENT } from "../lib/auth";
import { NewsletterModal } from "./NewsletterModal";
import { useCartFly } from "./CartFly";
@@ -131,6 +132,40 @@ function AccountLink() {
);
}
+// Wishlist icon + count badge — only rendered by the caller when
+// `wishlistEnabled` (CompanySettings), and even then hidden below `sm:`.
+// Account+Cart are the only always-visible icons on true mobile (see the
+// trailing-controls group's own comment: gap-2 there was already tuned
+// specifically for exactly 2 icons) — a 3rd icon squeezed in at the
+// smallest phone widths risks the exact nav-overflow class of bug
+// documented in the figma-to-nextjs skill (computed hamburger/icon-row
+// thresholds, not assumed ones). sm+ has real room to spare.
+function WishlistLink() {
+ const { count } = useWishlist();
+
+ return (
+ 0 ? `Merkliste, ${count} Artikel` : "Merkliste"}
+ className="relative hidden sm:flex h-11 w-11 items-center justify-center shrink-0 active:scale-[0.9] transition-transform"
+ >
+
+ {count > 0 && (
+
+ {count > 99 ? "99+" : count}
+
+ )}
+
+ );
+}
+
// Cart icon + count badge — traced from the Figma Navbar/Default component's
// btn-cart (icon-cart 32x30 + cart-count-badge, node 4849:24). Visible at
// every breakpoint tier (unlike the nav links / CTA buttons, which move into
@@ -210,7 +245,7 @@ function CartLink() {
);
}
-export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) {
+export function Navbar({ singleActiveProduct, wishlistEnabled }: { singleActiveProduct: boolean; wishlistEnabled: boolean }) {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [activeSection, setActiveSection] = useState("");
@@ -510,6 +545,7 @@ export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }
these two icons are the only always-visible controls. */}
diff --git a/app/components/WishlistButton.tsx b/app/components/WishlistButton.tsx
new file mode 100644
index 0000000..8aa4d9f
--- /dev/null
+++ b/app/components/WishlistButton.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { useWishlist } from "../lib/useWishlist";
+
+// Heart-toggle for a product card/detail page. Login-gated (unlike the
+// cart, which works for guests) — a logged-out click redirects to
+// /konto/login?redirect= instead of silently failing, since
+// there's no local-storage fallback that would make sense for a wishlist
+// (see useWishlist.ts's own comment on why this can't reuse cart.ts's
+// guest-friendly pattern).
+export function WishlistButton({
+ productId,
+ variant = "",
+ className = "",
+}: {
+ productId: number;
+ variant?: string;
+ className?: string;
+}) {
+ const { isWishlisted, toggle } = useWishlist();
+ const [pending, setPending] = useState(false);
+ const router = useRouter();
+ const wishlisted = isWishlisted(productId, variant);
+
+ async function handleClick(e: React.MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (pending) return;
+
+ const res = await fetch("/api/account/wishlist", { method: "GET", cache: "no-store" });
+ if (res.status === 401) {
+ router.push(`/konto/login?redirect=${encodeURIComponent(window.location.pathname)}`);
+ return;
+ }
+
+ setPending(true);
+ await toggle(productId, variant);
+ setPending(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/app/konto/merkliste/page.tsx b/app/konto/merkliste/page.tsx
new file mode 100644
index 0000000..8b7de53
--- /dev/null
+++ b/app/konto/merkliste/page.tsx
@@ -0,0 +1,124 @@
+import type { Metadata } from "next";
+import { redirect, notFound } from "next/navigation";
+import Link from "next/link";
+import Image from "next/image";
+import { Reveal, RevealGroup, RevealItem } from "../../components/Reveal";
+import { Footer } from "../../components/Footer";
+import { formatPrice, discountPercent } from "../../lib/format";
+import { getSessionCustomer, getWishlist } from "../../lib/customerAuth";
+import { getProductsByIds, getWishlistEnabled, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
+import { effectiveTaxRate } from "../../lib/cartTotals";
+import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
+import { WishlistButton } from "../../components/WishlistButton";
+
+// robots: noindex — account area, same reasoning as /konto/bestellungen.
+export const metadata: Metadata = {
+ title: "Meine Merkliste",
+ description: "Deine gemerkten Produkte bei einfach produktiv.",
+ robots: {
+ index: false,
+ follow: true,
+ },
+};
+
+export default async function KontoMerklistePage() {
+ const session = await getSessionCustomer();
+ if (!session) redirect("/konto/login");
+
+ // The feature can be switched off after a customer already had rows in
+ // their wishlist — 404 rather than showing a stale page for a feature
+ // that's no longer offered, same reasoning as any other feature-flagged
+ // route in this codebase.
+ const wishlistEnabled = await getWishlistEnabled();
+ if (!wishlistEnabled) notFound();
+
+ const [wishlistItems, defaultTaxRate, kleinunternehmer] = await Promise.all([
+ getWishlist(session.token, session.customer.id),
+ getDefaultTaxRatePercent(),
+ getKleinunternehmer(),
+ ]);
+ const products = await getProductsByIds(wishlistItems.map((i) => i.productId));
+ // Preserve the wishlist's own most-recently-added-first order rather
+ // than whatever order the products query happens to return in.
+ const productsByNumericId = new Map(products.map((p) => [p.numericId, p]));
+ const orderedProducts = wishlistItems
+ .map((item) => productsByNumericId.get(item.productId))
+ .filter((p): p is NonNullable => Boolean(p));
+
+ return (
+ <>
+
+
+
+ Meine Merkliste
+
+
+ {orderedProducts.length === 0 ? (
+ Du hast noch keine Produkte gemerkt.
+ ) : (
+
+ {orderedProducts.map((product) => {
+ const discount = discountPercent(product.price, product.compareAtPrice);
+ const taxRate = effectiveTaxRate(product, defaultTaxRate);
+ const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock;
+ return (
+
+
+
+ {fullyOutOfStock && (
+
+ Ausverkauft
+
+ )}
+
+
+
+
+ {product.name}
+
+
+
+ {discount !== null && (
+ {formatPrice(product.compareAtPrice!)}
+ )}
+ {formatPrice(product.price)}
+ {!kleinunternehmer && inkl. {taxRate}% MwSt.}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ Meine Bestellungen
+
+
+ Weiter einkaufen
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index adeaa83..4337f59 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -4,7 +4,7 @@ import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { CartSync } from "./components/CartSync";
-import { getProducts, getSeoSettings, getCompanySettings } from "./lib/payload";
+import { getProducts, getSeoSettings, getCompanySettings, getWishlistEnabled } from "./lib/payload";
import { buildOrganizationSchema } from "./lib/structuredData";
const inter = Inter({
@@ -67,7 +67,7 @@ export default async function RootLayout({
// just to know whether Navbar's "Shop" link should behave as an anchor
// to the homepage spotlight instead of a real /shop navigation (see
// Navbar.tsx/ProductSpotlight.tsx).
- const [products, seller] = await Promise.all([getProducts(), getCompanySettings()]);
+ const [products, seller, wishlistEnabled] = await Promise.all([getProducts(), getCompanySettings(), getWishlistEnabled()]);
const singleActiveProduct = products.filter((p) => p.active).length === 1;
// Organization JSON-LD on every page — one canonical node (@id) that
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
@@ -87,7 +87,7 @@ export default async function RootLayout({
-
+
{children}