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
+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).