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. */}
+ {wishlistEnabled && }
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 ( + +
+ {product.name} + {fullyOutOfStock && ( + + Ausverkauft + + )} + +
+
+

+ {product.name} +

+
+

+ {discount !== null && ( + {formatPrice(product.compareAtPrice!)} + )} + {formatPrice(product.price)} + {!kleinunternehmer && inkl. {taxRate}% MwSt.} +

+
+ +
+
+ ); + })} +
+ )} + +
+ + Meine Bestellungen + + + Weiter einkaufen + +
+
+
+