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
+26
View File
@@ -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 });
}
+37 -1
View File
@@ -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 (
<Link
href="/konto/merkliste"
aria-label={count > 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"
>
<svg viewBox="0 0 20 18" className="h-6 w-6 text-text-primary" fill="none" aria-hidden="true">
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
{count > 0 && (
<span className="absolute top-0 right-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-brand px-1 text-[0.6875rem] font-bold leading-none text-white">
{count > 99 ? "99+" : count}
</span>
)}
</Link>
);
}
// 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. */}
<div className="flex items-center">
<AccountLink />
{wishlistEnabled && <WishlistLink />}
<CartLink />
</div>
+62
View File
@@ -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=<back-here> 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 (
<button
type="button"
onClick={handleClick}
aria-label={wishlisted ? "Von der Merkliste entfernen" : "Zur Merkliste hinzufügen"}
aria-pressed={wishlisted}
disabled={pending}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-bg-base/90 backdrop-blur-sm transition-transform active:scale-90 disabled:opacity-60 ${className}`}
>
<svg width="20" height="18" viewBox="0 0 20 18" fill={wishlisted ? "currentColor" : "none"} className={wishlisted ? "text-brand" : "text-text-primary"}>
<path
d="M10 17S1 11.5 1 5.8C1 2.6 3.4 1 5.8 1c1.6 0 3.2.9 4.2 2.4C11 1.9 12.6 1 14.2 1 16.6 1 19 2.6 19 5.8 19 11.5 10 17 10 17Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
+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 />
</>
);
}
+3 -3
View File
@@ -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({
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
<CartFlyProvider>
<CartSync />
<Navbar singleActiveProduct={singleActiveProduct} />
<Navbar singleActiveProduct={singleActiveProduct} wishlistEnabled={wishlistEnabled} />
{children}
</CartFlyProvider>
</body>
+1
View File
@@ -4,6 +4,7 @@ import type { Product } from "../payload";
const product = (overrides: Partial<Product> = {}): Product => ({
id: "todo-karten",
numericId: 1,
name: "ToDo-Karten",
description: "",
price: 12.9,
+65
View File
@@ -357,6 +357,71 @@ export async function changeCustomerPassword(
return { ok: true };
}
export type WishlistItem = {
id: number;
productId: number;
variant: string;
};
// `variant` empty string, not undefined — matches WishlistItems.ts's own
// defaultValue: '' so the (customer, product, variant) unique index
// actually catches a duplicate add for a variant-less product too.
export async function getWishlist(token: string, customerId: number): Promise<WishlistItem[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
depth: "0",
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return [];
const data: { docs?: { id: number; product: number; variant?: string }[] } = await res.json();
return (data.docs ?? []).map((doc) => ({ id: doc.id, productId: doc.product, variant: doc.variant ?? "" }));
}
// Toggles a single (product, variant) — tries to create first; a 400 here
// means the unique (customer, product, variant) index rejected it because
// it already exists, so this falls back to finding + deleting that row
// instead. Avoids a separate "is it already wishlisted" read before every
// toggle (the common case, adding something new, only needs one request).
export async function toggleWishlistItem(
token: string,
productId: number,
variant: string,
): Promise<{ ok: true; wishlisted: boolean } | { ok: false }> {
const createRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items`, {
method: "POST",
headers: { Authorization: `JWT ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ product: productId, variant }),
});
if (createRes.ok) return { ok: true, wishlisted: true };
const findParams = new URLSearchParams({
"where[product][equals]": String(productId),
"where[variant][equals]": variant,
depth: "0",
limit: "1",
});
const findRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${findParams}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!findRes.ok) return { ok: false };
const found: { docs?: { id: number }[] } = await findRes.json();
const existingId = found.docs?.[0]?.id;
if (!existingId) return { ok: false };
const deleteRes = await fetch(`${PAYLOAD_URL}/api/wishlist-items/${existingId}`, {
method: "DELETE",
headers: { Authorization: `JWT ${token}` },
});
if (!deleteRes.ok) return { ok: false };
return { ok: true, wishlisted: false };
}
// Called from app/api/account/verify-email/route.ts — no customer session
// exists at this point (cold click from an email client), so this
// authenticates as the service instead (see SERVICE_SECRET above).
+45
View File
@@ -173,6 +173,13 @@ export async function getPostBySlug(slug: string, options?: { draft?: boolean })
// to numeric ids here would silently orphan every existing shopper's cart.
export type Product = {
id: string;
// The raw Payload numeric id — `id` above is the slug (used everywhere
// as the "commerce id" — cart, checkout, URLs), but a few relationships
// (Orders.items.product, WishlistItems.product) are real Payload
// relationship fields storing this number instead. Kept alongside the
// slug rather than replacing it, to avoid touching every existing
// slug-based call site.
numericId: number;
name: string;
description: string;
price: number;
@@ -290,6 +297,7 @@ function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowB
export function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
numericId: product.id,
name: product.name,
description: product.description ?? "",
price: product.price,
@@ -353,6 +361,23 @@ export async function getProductBySlug(slug: string): Promise<Product | null> {
// from getProducts()'s slug-keyed catalog (an order can reference a
// product that's since been deactivated/deleted, and slugs aren't even
// the key an order item stores).
// Powers /konto/merkliste — WishlistItems.product is a real numeric
// relationship (see Product.numericId's own comment), so displaying the
// wishlist needs a numeric-id lookup rather than getProducts()'s
// slug-keyed list.
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) return [];
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
if (!res.ok) {
console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadProduct[] } = await res.json();
return (data.docs ?? []).map(mapPayloadProduct);
}
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
const uniqueIds = [...new Set(ids)];
const map = new Map<number, string>();
@@ -977,6 +1002,26 @@ export async function getKleinunternehmer(): Promise<boolean> {
return data.docs?.[0]?.kleinunternehmer ?? false;
}
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
// the Navbar link) site-wide. Deliberately off by default (see
// CompanySettings.ts's own field comment) so the feature stays entirely
// invisible in the frontend until a tenant actually wants it, rather than
// shipping a half-finished-looking icon everywhere.
export async function getWishlistEnabled(): Promise<boolean> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
return false;
}
const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
return data.docs?.[0]?.wishlistEnabled ?? false;
}
export type SeoSettings = {
defaultTitle: string | null;
titleTemplate: string | null;
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Server-backed (needs a logged-in customer, unlike the cart which works
// for guests via localStorage — a wishlist tied to nothing would just
// evaporate on the next visit, which defeats the point) — so this can't
// reuse cart.ts's useSyncExternalStore-over-localStorage pattern. Instead:
// a plain fetch on mount + a custom window event so every WishlistButton/
// the Navbar badge on the page stays in sync after any one of them toggles
// an item, without a shared cache library.
const WISHLIST_EVENT = "ep-wishlist-updated";
type WishlistItem = { id: number; productId: number; variant: string };
let cachedItems: WishlistItem[] | null = null;
async function fetchWishlist(): Promise<WishlistItem[]> {
const res = await fetch("/api/account/wishlist", { cache: "no-store" });
if (!res.ok) return [];
const data: { items?: WishlistItem[] } = await res.json();
return data.items ?? [];
}
function broadcast() {
window.dispatchEvent(new Event(WISHLIST_EVENT));
}
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>(cachedItems ?? []);
const [loading, setLoading] = useState(cachedItems === null);
const load = useCallback(async () => {
const fresh = await fetchWishlist();
cachedItems = fresh;
setItems(fresh);
setLoading(false);
}, []);
useEffect(() => {
load();
window.addEventListener(WISHLIST_EVENT, load);
return () => window.removeEventListener(WISHLIST_EVENT, load);
}, [load]);
const isWishlisted = useCallback(
(productId: number, variant = "") => items.some((i) => i.productId === productId && i.variant === variant),
[items],
);
// Optimistic: flips the local list immediately, reconciles with the
// server response (or reverts on failure) rather than waiting for the
// round trip — same "feels instant" reasoning as AddToCartButton.
const toggle = useCallback(async (productId: number, variant = "") => {
const wasWishlisted = cachedItems?.some((i) => i.productId === productId && i.variant === variant) ?? false;
const optimistic = wasWishlisted
? (cachedItems ?? []).filter((i) => !(i.productId === productId && i.variant === variant))
: [...(cachedItems ?? []), { id: -1, productId, variant }];
cachedItems = optimistic;
setItems(optimistic);
broadcast();
try {
const res = await fetch("/api/account/wishlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId, variant }),
});
if (res.status === 401) {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
broadcast();
return { ok: false as const, unauthorized: true as const };
}
if (!res.ok) throw new Error("request failed");
const data: { ok: boolean; wishlisted?: boolean } = await res.json();
if (!data.ok) throw new Error("toggle failed");
// Reconcile with the server's own id (needed for a later toggle-off
// that hasn't refetched the list yet) rather than trusting the
// optimistic placeholder id (-1) forever.
await load();
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
} catch {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
broadcast();
return { ok: false as const };
}
}, []);
return { items, loading, isWishlisted, toggle, count: items.length };
}
+7 -2
View File
@@ -1,10 +1,11 @@
import Link from "next/link";
import Image from "next/image";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer } from "../../lib/payload";
import { getProducts, getShippingSettings, getDefaultTaxRatePercent, getKleinunternehmer, getWishlistEnabled } from "../../lib/payload";
import { effectiveTaxRate } from "../../lib/cartTotals";
import { formatPrice, discountPercent } from "../../lib/format";
import { RevealGroup, RevealItem } from "../../components/Reveal";
import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
import { WishlistButton } from "../../components/WishlistButton";
// Server Component — fetches straight from Payload (getProducts(), ISR
// cached 60s) rather than going through the client-side useProducts()
@@ -13,11 +14,12 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton";
// gives faster first paint and no loading flash.
export async function ProductGrid() {
const [allProducts, shipping, defaultTaxRate, kleinunternehmer] = await Promise.all([
const [allProducts, shipping, defaultTaxRate, kleinunternehmer, wishlistEnabled] = await Promise.all([
getProducts(),
getShippingSettings(),
getDefaultTaxRatePercent(),
getKleinunternehmer(),
getWishlistEnabled(),
]);
const products = allProducts.filter((p) => p.active);
@@ -73,6 +75,9 @@ export async function ProductGrid() {
</span>
)
)}
{wishlistEnabled && (
<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