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 (
-
+
- {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