From 230b8ebaadc5d87aeaf948641917800ec3b7b0f2 Mon Sep 17 00:00:00 2001 From: Marco Date: Fri, 31 Jul 2026 13:51:10 +0000 Subject: [PATCH] Mark already-purchased items on the wishlist instead of auto-removing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 23 ++++++++ .../merkliste/components/MerklisteGrid.tsx | 37 ++++++++---- app/lib/customerAuth.ts | 56 +++++++++++++++++-- app/lib/useWishlist.ts | 8 +-- 4 files changed, 104 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ced9ff1..f0a8f88 100644 --- a/README.md +++ b/README.md @@ -2313,6 +2313,29 @@ features requires the matching `CompanySettings` toggle to actually be switched on before it shows up anywhere in the frontend — if a feature "isn't showing," check that first. +## Wishlist "already purchased" marker (2026-07-31) + +Decided against auto-removing a wishlist item once the customer buys it — +a customer often wishlists something specifically *to* buy it again +(gifts for multiple people, anything they'd repurchase), so silently +removing it right when they'd next want it defeats the point. Instead, +`/konto/merkliste` (`MerklisteGrid.tsx`) marks it: dimmed image + a +"Gekauft am [date]" badge (replaces the "Ausverkauft" badge when both +would apply — already-owning it matters more than a restock notice). +Removal stays entirely manual, same `WishlistButton` as always. + +- **`WishlistItem.purchasedAt`** (`app/lib/customerAuth.ts`) — never + persisted on the backend's `wishlist-items` collection (its `update` + access is hard-disabled by design); derived fresh on every + `getWishlist()` call by cross-referencing the customer's own orders + (`getPurchasedVariantMap()`), matched on the exact `(product, variant)` + pair a wishlist row represents. Excludes `cancelled`/`returned` orders + — an aborted or refunded order doesn't mean the customer actually owns + the item. +- Flows through `useWishlist.ts`'s existing item shape/cache/broadcast + mechanism unchanged — this is purely an added read-only field, not a + new toggle or a second collection. + ## DHL checkout integrations (2026-07-31) Three checkout-facing pieces added, each invisible unless the tenant's diff --git a/app/konto/merkliste/components/MerklisteGrid.tsx b/app/konto/merkliste/components/MerklisteGrid.tsx index bcb55c2..57c2d68 100644 --- a/app/konto/merkliste/components/MerklisteGrid.tsx +++ b/app/konto/merkliste/components/MerklisteGrid.tsx @@ -2,7 +2,7 @@ import Image from "next/image"; import { RevealGroup, RevealItem } from "../../../components/Reveal"; -import { formatPrice, discountPercent } from "../../../lib/format"; +import { formatPrice, discountPercent, formatDate } from "../../../lib/format"; import { AddToCartInlineButton } from "../../../components/AddToCartInlineButton"; import { WishlistButton } from "../../../components/WishlistButton"; import { useWishlist } from "../../../lib/useWishlist"; @@ -27,36 +27,51 @@ export function MerklisteGrid({ kleinunternehmer: boolean; }) { const { items } = useWishlist(); - const wishlistedIds = new Set(items.map((i) => i.productId)); // Preserve the wishlist's own order (most-recently-added-first, via - // `items`) rather than initialProducts' own order. + // `items`) rather than initialProducts' own order. Zipped with the + // originating wishlist item (not just the product) so purchasedAt stays + // attached — the earlier `.map().filter()` chain that only kept the + // product lost that association. const productsByNumericId = new Map(initialProducts.map((p) => [p.numericId, p])); - const visibleProducts = items.map((i) => productsByNumericId.get(i.productId)).filter((p): p is Product => Boolean(p)); + const visibleEntries = items + .map((item) => ({ item, product: productsByNumericId.get(item.productId) })) + .filter((entry): entry is { item: (typeof items)[number]; product: Product } => Boolean(entry.product)); - if (visibleProducts.length === 0) { + if (visibleEntries.length === 0) { return

Du hast noch keine Produkte gemerkt.

; } return ( - {visibleProducts.map((product) => { + {visibleEntries.map(({ item, product }) => { const discount = discountPercent(product.price, product.compareAtPrice); const taxRate = effectiveTaxRate(product, defaultTaxRate); const fullyOutOfStock = product.variants.length > 0 ? product.variants.every((v) => v.outOfStock) : product.outOfStock; + // Already-purchased takes precedence over "Ausverkauft" — a + // customer who already bought this doesn't need a restock notice, + // they need to know they already own it (and can still remove it + // manually via WishlistButton — this is a status note, not an + // auto-removal, see feedback discussion this implements). return ( - +
{product.name} - {fullyOutOfStock && ( - - Ausverkauft + {item.purchasedAt ? ( + + Gekauft am {formatDate(item.purchasedAt)} + ) : ( + fullyOutOfStock && ( + + Ausverkauft + + ) )}
diff --git a/app/lib/customerAuth.ts b/app/lib/customerAuth.ts index 9e61412..d1f7d49 100644 --- a/app/lib/customerAuth.ts +++ b/app/lib/customerAuth.ts @@ -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> { + 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(); + 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 ({ 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 diff --git a/app/lib/useWishlist.ts b/app/lib/useWishlist.ts index 5971df4..0f52e14 100644 --- a/app/lib/useWishlist.ts +++ b/app/lib/useWishlist.ts @@ -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 }; }