Add wishlist feature (frontend), gated by CompanySettings.wishlistEnabled

New: useWishlist.ts (fetch+optimistic-toggle hook, server-backed since
a wishlist needs a logged-in customer, unlike the guest-friendly cart),
WishlistButton.tsx (heart toggle, login-redirects on 401), Navbar's
wishlist icon+badge (hidden below sm: — Account+Cart are the only
always-visible icons on true mobile, a 3rd icon there risks the same
computed nav-overflow class of bug documented in the figma-to-nextjs
skill), and /konto/merkliste (list page, 404s if the feature gets
disabled after a customer already has rows).

Product gained numericId (the raw Payload id) alongside its existing
slug id — WishlistItems.product is a real numeric relationship field,
unlike cart/checkout's slug-keyed "commerce id".

Backend counterpart (WishlistItems collection, CompanySettings toggle,
migration) already deployed separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-30 22:28:30 +00:00
parent 1d91a3f21c
commit 11aa9689c1
10 changed files with 462 additions and 6 deletions
+124
View File
@@ -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<typeof p> => Boolean(p));
return (
<>
<main className="flex flex-col flex-1 bg-bg-base">
<Reveal className="flex flex-col gap-6 items-start pt-10 pb-16 px-[var(--layout-padding-x)] w-full max-w-[75rem] mx-auto">
<p className="font-semibold text-h-feature text-text-primary" style={{ fontFamily: "var(--font-lora)" }}>
Meine Merkliste
</p>
{orderedProducts.length === 0 ? (
<p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>
) : (
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{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 (
<RevealItem
key={product.id}
className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full"
>
<div className="relative w-full aspect-[276/210] overflow-hidden">
<Image
src={product.image}
alt={product.name}
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
/>
{fullyOutOfStock && (
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
Ausverkauft
</span>
)}
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
</div>
<div className="flex flex-col gap-4 items-start px-5 pb-5 pt-4 w-full flex-1">
<p className="font-semibold text-h4 text-text-primary w-full" style={{ fontFamily: "var(--font-lora)" }}>
{product.name}
</p>
<div className="flex flex-col gap-1 items-start">
<p className="flex items-baseline gap-1.5">
{discount !== null && (
<span className="text-label text-text-muted line-through">{formatPrice(product.compareAtPrice!)}</span>
)}
<span className="font-bold text-h4 text-text-primary">{formatPrice(product.price)}</span>
{!kleinunternehmer && <span className="text-label text-text-muted">inkl. {taxRate}% MwSt.</span>}
</p>
</div>
<AddToCartInlineButton
id={product.id}
outOfStock={product.outOfStock}
maxQty={product.maxQty}
variants={product.variants}
className="w-full"
/>
</div>
</RevealItem>
);
})}
</RevealGroup>
)}
<div className="flex gap-6 self-center sm:self-start">
<Link href="/konto/bestellungen" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Meine Bestellungen
</Link>
<Link href="/shop" className="underline text-body-sm text-text-primary hover:text-brand transition-colors">
Weiter einkaufen
</Link>
</div>
</Reveal>
</main>
<Footer />
</>
);
}