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