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