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:
@@ -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
|
||||
|
||||
@@ -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 <p className="text-body text-text-muted">Du hast noch keine Produkte gemerkt.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
|
||||
{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 (
|
||||
<RevealItem key={product.id} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
|
||||
<RevealItem key={`${product.id}-${item.variant}`} className="bg-bg-base border border-border rounded-md overflow-hidden flex flex-col h-full">
|
||||
<div className="relative w-full aspect-[276/210] overflow-hidden">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
||||
className={`object-cover ${fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
className={`object-cover ${item.purchasedAt || fullyOutOfStock ? "opacity-60" : ""}`}
|
||||
/>
|
||||
{fullyOutOfStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
{item.purchasedAt ? (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-success-subtle px-2.5 py-1 text-label font-bold text-success">
|
||||
Gekauft am {formatDate(item.purchasedAt)}
|
||||
</span>
|
||||
) : (
|
||||
fullyOutOfStock && (
|
||||
<span className="absolute top-3 left-3 rounded-full bg-text-muted px-2.5 py-1 text-label font-bold text-bg-base">
|
||||
Ausverkauft
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<WishlistButton productId={product.numericId} className="absolute top-3 right-3" />
|
||||
</div>
|
||||
|
||||
+51
-5
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user