Mark already-purchased items on the wishlist instead of auto-removing

A customer often wishlists something specifically to buy it again (gifts, repurchases) — silently removing it after purchase would defeat that. /konto/merkliste now shows a dimmed image + "Gekauft am [date]" badge instead, derived read-only from the customer's own orders (cancelled/returned orders excluded). Removal stays manual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-31 13:51:10 +00:00
parent 8a4170a1e6
commit 230b8ebaad
4 changed files with 104 additions and 20 deletions
+51 -5
View File
@@ -361,8 +361,48 @@ export type WishlistItem = {
id: number;
productId: number;
variant: string;
/** ISO date of the most recent non-cancelled/non-returned order
* containing this exact (product, variant), or null if never bought.
* Deliberately never persisted on wishlist-items itself (that
* collection's own `update` access is hard-disabled — see its own
* comment) — always derived fresh from Orders on every wishlist read,
* same "read-only annotation" approach used elsewhere in this file. */
purchasedAt: string | null;
};
// Keyed `${productId}:${variant}` (empty-string variant included, matching
// WishlistItems' own key shape) → the most recent qualifying order's
// createdAt. "Qualifying" excludes `cancelled` (aborted/failed order, never
// actually fulfilled) and `returned` (customer no longer has the item) —
// everything else (received/processing/shipped/delivered/return_requested)
// still counts as "they did buy this," which is the plain-language meaning
// of the wishlist badge this feeds.
async function getPurchasedVariantMap(token: string, customerId: number): Promise<Map<string, string>> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[status][not_in]": "cancelled,returned",
depth: "0",
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/orders?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
if (!res.ok) return new Map();
const data: { docs?: { createdAt: string; items: { product: number; variantName?: string | null }[] }[] } = await res.json();
const map = new Map<string, string>();
for (const order of data.docs ?? []) {
for (const item of order.items) {
const key = `${item.product}:${item.variantName ?? ""}`;
// `sort: "-createdAt"` above means the first order seen per key is
// already the most recent — never overwrite with an older one.
if (!map.has(key)) map.set(key, order.createdAt);
}
}
return map;
}
// `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.
@@ -373,13 +413,19 @@ export async function getWishlist(token: string, customerId: number): Promise<Wi
limit: "200",
sort: "-createdAt",
});
const res = await fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
});
const [res, purchasedMap] = await Promise.all([
fetch(`${PAYLOAD_URL}/api/wishlist-items?${params}`, {
headers: { Authorization: `JWT ${token}` },
cache: "no-store",
}),
getPurchasedVariantMap(token, customerId),
]);
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 ?? "" }));
return (data.docs ?? []).map((doc) => {
const variant = doc.variant ?? "";
return { id: doc.id, productId: doc.product, variant, purchasedAt: purchasedMap.get(`${doc.product}:${variant}`) ?? null };
});
}
// Toggles a single (product, variant) — tries to create first; a 400 here
+4 -4
View File
@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from "react";
// an item, without a shared cache library.
const WISHLIST_EVENT = "ep-wishlist-updated";
type WishlistItem = { id: number; productId: number; variant: string };
type WishlistItem = { id: number; productId: number; variant: string; purchasedAt: string | null };
let cachedItems: WishlistItem[] | null = null;
@@ -63,7 +63,7 @@ export function useWishlist() {
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 ?? []), { id: -1, productId, variant, purchasedAt: null }];
cachedItems = optimistic;
setItems(optimistic);
@@ -74,7 +74,7 @@ export function useWishlist() {
body: JSON.stringify({ productId, variant }),
});
if (res.status === 401) {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const, unauthorized: true as const };
}
@@ -88,7 +88,7 @@ export function useWishlist() {
broadcast();
return { ok: true as const, wishlisted: data.wishlisted ?? !wasWishlisted };
} catch {
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant }] : optimistic.filter((i) => i.productId !== productId);
cachedItems = wasWishlisted ? [...optimistic, { id: -1, productId, variant, purchasedAt: null }] : optimistic.filter((i) => i.productId !== productId);
setItems(cachedItems);
return { ok: false as const };
}