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:
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user