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
+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;