+ {!hasUnseenNotification && matchedFilterId && (
+
+
+
+ )}
{product.images.length > 0 ? (
isRestrictedImage(
product.images[0],
diff --git a/src/components/product/detail/similar/__tests__/ProductSimilar.test.tsx b/src/components/product/detail/similar/__tests__/ProductSimilar.test.tsx
index 7654c9f6..96f0ba28 100644
--- a/src/components/product/detail/similar/__tests__/ProductSimilar.test.tsx
+++ b/src/components/product/detail/similar/__tests__/ProductSimilar.test.tsx
@@ -190,6 +190,30 @@ describe("ProductSimilar", () => {
expect(screen.getByText("Keine ähnlichen Artikel")).toBeInTheDocument();
});
+ it("should render HiddenMatchCard instead of ProductSimilarCard when product is hidden", () => {
+ const hiddenProduct: OverviewProduct = {
+ ...mockProducts[0],
+ productId: "00000000-0000-0000-0000-000000000000",
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: { matched: true, hidden: true },
+ },
+ };
+ vi.mocked(useSimilarProducts).mockReturnValue({
+ data: { isEmbeddingsPending: false, products: [hiddenProduct] },
+ isLoading: false,
+ isError: false,
+ error: null,
+ } as never);
+
+ render(
);
+
+ expect(screen.getByText(/Verborgen/i)).toBeInTheDocument();
+ expect(screen.queryByText(mockProducts[0].title)).not.toBeInTheDocument();
+ });
+
it("should render similar products correctly in grid", () => {
vi.mocked(useSimilarProducts).mockReturnValue({
data: { isEmbeddingsPending: false, products: mockProducts },
diff --git a/src/components/product/detail/similar/__tests__/ProductSimilarCard.test.tsx b/src/components/product/detail/similar/__tests__/ProductSimilarCard.test.tsx
index c61efab7..316aaf10 100644
--- a/src/components/product/detail/similar/__tests__/ProductSimilarCard.test.tsx
+++ b/src/components/product/detail/similar/__tests__/ProductSimilarCard.test.tsx
@@ -181,6 +181,67 @@ describe("ProductSimilarCard", () => {
expect(priceElement).not.toBeInTheDocument();
});
+ describe("search filter highlight", () => {
+ const mockProductMatched: OverviewProduct = {
+ ...mockProduct,
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: {
+ matched: true,
+ hidden: false,
+ userSearchFilterId: "filter-123",
+ userSearchFilterName: "Jugendstil Schmuck",
+ matchReason: "Passt zum Suchauftrag.",
+ },
+ },
+ };
+
+ it("should render border-tertiary when matched and not hidden", () => {
+ const { container } = render(
);
+ expect(container.querySelector(".border-tertiary")).toBeInTheDocument();
+ });
+
+ it("should render the search filter match badge", () => {
+ render(
);
+ expect(screen.getByText("Treffer")).toBeInTheDocument();
+ });
+
+ it("should NOT render border-tertiary when not matched", () => {
+ const { container } = render(
);
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+
+ it("should NOT render match badge or border-tertiary when hidden=true", () => {
+ const hiddenProduct: OverviewProduct = {
+ ...mockProduct,
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: { matched: true, hidden: true },
+ },
+ };
+ const { container } = render(
);
+ expect(screen.queryByText("Treffer")).not.toBeInTheDocument();
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+
+ it("should prefer border-primary over border-tertiary when notification is present", () => {
+ const productWithBoth: OverviewProduct = {
+ ...mockProductMatched,
+ userData: {
+ ...mockProductMatched.userData!,
+ notificationData: { hasUnseenNotification: true, originEventId: "event-123" },
+ },
+ };
+ const { container } = render(
);
+ expect(container.querySelector(".border-primary")).toBeInTheDocument();
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+ });
+
describe("unseen notification highlight", () => {
const mockProductWithUnseenNotification: OverviewProduct = {
...mockProduct,
diff --git a/src/components/product/overview/ProductCard.tsx b/src/components/product/overview/ProductCard.tsx
index ce1e8158..49b36008 100644
--- a/src/components/product/overview/ProductCard.tsx
+++ b/src/components/product/overview/ProductCard.tsx
@@ -2,6 +2,7 @@ import { StatusBadge } from "@/components/product/badges/StatusBadge.tsx";
import { ShopTypeBadge } from "@/components/product/badges/ShopTypeBadge.tsx";
import { AuctionWindowBadge } from "@/components/product/badges/AuctionWindowBadge.tsx";
import { UnseenNotificationBadge } from "@/components/product/badges/UnseenNotificationBadge.tsx";
+import { SearchFilterMatchBadge } from "@/components/product/badges/SearchFilterMatchBadge.tsx";
import { H2 } from "@/components/typography/H2.tsx";
import { PriceText } from "@/components/typography/PriceText.tsx";
import { Button } from "@/components/ui/button.tsx";
@@ -23,6 +24,12 @@ function ProductCardComponent({ product }: { readonly product: OverviewProduct }
const originEventId = product.userData?.notificationData?.originEventId;
const markSeen = useMarkNotificationSeen();
+ const searchFilterData = product.userData?.searchFilterData;
+ const matchedFilterId =
+ searchFilterData?.matched && !searchFilterData.hidden
+ ? searchFilterData.userSearchFilterId
+ : undefined;
+
const isRemoved = product.state === "REMOVED";
const merchantUrl = product.viewUrl?.href ?? product.url?.href;
@@ -38,6 +45,7 @@ function ProductCardComponent({ product }: { readonly product: OverviewProduct }
"relative flex h-full min-w-0 flex-col overflow-hidden border border-outline-variant/20 bg-surface-container-lowest transition-all duration-300 ease-out",
"shadow-[0_12px_40px_rgba(28,28,22,0.06)]",
hasUnseenNotification && "border-primary",
+ !hasUnseenNotification && matchedFilterId && "border-tertiary",
)}
>
@@ -54,6 +62,16 @@ function ProductCardComponent({ product }: { readonly product: OverviewProduct }
)}
+
+ {!hasUnseenNotification && matchedFilterId && (
+
+
+
+ )}
diff --git a/src/components/product/overview/__tests__/ProductCard.test.tsx b/src/components/product/overview/__tests__/ProductCard.test.tsx
index 8b0eeed8..b0704f05 100644
--- a/src/components/product/overview/__tests__/ProductCard.test.tsx
+++ b/src/components/product/overview/__tests__/ProductCard.test.tsx
@@ -145,6 +145,92 @@ describe("ProductCard", () => {
expect(screen.getByRole("button", { name: "Zur Seite des Händlers" })).toBeDisabled();
});
+ describe("search filter highlight", () => {
+ const mockProductMatched: OverviewProduct = {
+ ...mockProduct,
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: {
+ matched: true,
+ hidden: false,
+ userSearchFilterId: "filter-123",
+ userSearchFilterName: "Vintage Art Deco",
+ matchReason: "Passt zum gesuchten Vintage Art Deco Stil.",
+ },
+ },
+ };
+
+ it("should render border-tertiary when matched and not hidden", async () => {
+ const { container } = await act(() =>
+ renderWithRouter(
),
+ );
+ expect(container.querySelector(".border-tertiary")).toBeInTheDocument();
+ });
+
+ it("should render the search filter match badge", async () => {
+ await act(() => {
+ renderWithRouter(
);
+ });
+ expect(screen.getByText("Treffer")).toBeInTheDocument();
+ });
+
+ it("should NOT render border-tertiary when not matched", async () => {
+ const { container } = await act(() =>
+ renderWithRouter(
),
+ );
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+
+ it("should NOT render match badge when notification badge is shown", async () => {
+ const productWithBoth: OverviewProduct = {
+ ...mockProductMatched,
+ userData: {
+ ...mockProductMatched.userData!,
+ notificationData: { hasUnseenNotification: true, originEventId: "event-123" },
+ },
+ };
+ await act(() => {
+ renderWithRouter(
);
+ });
+ expect(screen.queryByText("Treffer")).not.toBeInTheDocument();
+ expect(screen.getByText("Aktualisiert")).toBeInTheDocument();
+ });
+
+ it("should prefer border-primary over border-tertiary when notification is present", async () => {
+ const productWithBoth: OverviewProduct = {
+ ...mockProductMatched,
+ userData: {
+ ...mockProductMatched.userData!,
+ notificationData: { hasUnseenNotification: true, originEventId: "event-123" },
+ },
+ };
+ const { container } = await act(() =>
+ renderWithRouter(
),
+ );
+ expect(container.querySelector(".border-primary")).toBeInTheDocument();
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+
+ it("should NOT render match badge or border-tertiary when hidden=true", async () => {
+ const hiddenProduct: OverviewProduct = {
+ ...mockProduct,
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: { matched: true, hidden: true },
+ },
+ };
+ const { container } = await act(() =>
+ renderWithRouter(
),
+ );
+ expect(screen.queryByText("Treffer")).not.toBeInTheDocument();
+ expect(container.querySelector(".border-tertiary")).not.toBeInTheDocument();
+ });
+ });
+
describe("unseen notification highlight", () => {
const mockProductWithUnseenNotification: OverviewProduct = {
...mockProduct,
diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx
index 6dd9a973..b613e1af 100644
--- a/src/components/search/SearchResults.tsx
+++ b/src/components/search/SearchResults.tsx
@@ -1,4 +1,5 @@
import { ProductCard } from "@/components/product/overview/ProductCard.tsx";
+import { HiddenMatchCard } from "@/components/product/overview/HiddenMatchCard.tsx";
import { ProductCardSkeleton } from "@/components/product/overview/ProductCardSkeleton.tsx";
import { SectionInfoText } from "@/components/typography/SectionInfoText.tsx";
import { useEffect } from "react";
@@ -78,9 +79,14 @@ export function SearchResults({ searchFilters, onTotalChange }: SearchResultsPro
return (
- {allProducts.map((product) => (
-
- ))}
+ {allProducts.map((product) => {
+ const isHidden = product.userData?.searchFilterData?.hidden === true;
+ return isHidden ? (
+
+ ) : (
+
+ );
+ })}
{showLoaderRow && (
diff --git a/src/components/search/__tests__/SearchResults.test.tsx b/src/components/search/__tests__/SearchResults.test.tsx
index f0ba79cd..f0a0bda1 100644
--- a/src/components/search/__tests__/SearchResults.test.tsx
+++ b/src/components/search/__tests__/SearchResults.test.tsx
@@ -123,6 +123,37 @@ describe("SearchResults", () => {
expect(onTotalChange).toHaveBeenCalledWith(0);
});
+ it("renders HiddenMatchCard instead of ProductCard when product is hidden", () => {
+ const hiddenProduct: OverviewProduct = {
+ productId: "00000000-0000-0000-0000-000000000000",
+ eventId: "e1",
+ shopId: "s1",
+ shopSlugId: "shop-1",
+ shopsProductId: "si1",
+ productSlugId: "hidden",
+ title: "Inhalt verborgen",
+ shopName: "Unbekannter Händler",
+ sellerName: "Unbekannter Händler",
+ shopType: "AUCTION_HOUSE",
+ price: undefined,
+ state: "AVAILABLE",
+ url: null,
+ images: [],
+ created: new Date(),
+ updated: new Date(),
+ userData: {
+ watchlistData: { isWatching: false, isNotificationEnabled: false },
+ notificationData: { hasUnseenNotification: false },
+ restrictedContentData: { consentGiven: false },
+ searchFilterData: { matched: true, hidden: true },
+ },
+ };
+ setSearchMock({ products: [hiddenProduct] });
+ renderWithQueryClient(
);
+ expect(screen.getByText(/Verborgen/i)).toBeInTheDocument();
+ expect(screen.getByText(/Kontingent/i)).toBeInTheDocument();
+ });
+
it("renders a list of product cards when products are found", () => {
const base: Omit
= {
eventId: "e1",
diff --git a/src/data/internal/product/UserProductData.ts b/src/data/internal/product/UserProductData.ts
index 726adf93..3e00aa35 100644
--- a/src/data/internal/product/UserProductData.ts
+++ b/src/data/internal/product/UserProductData.ts
@@ -6,6 +6,7 @@ export type SearchFilterProductData = {
readonly matchFeedback?: boolean;
readonly matchReason?: string;
readonly userSearchFilterId?: string;
+ readonly userSearchFilterName?: string;
};
export type UserProductData = {
@@ -48,6 +49,7 @@ export function mapToInternalUserProductData(apiData: ProductUserStateData): Use
matchFeedback: apiData.searchFilter.matchFeedback,
matchReason: apiData.searchFilter.matchReason,
userSearchFilterId: apiData.searchFilter.userSearchFilterId,
+ userSearchFilterName: apiData.searchFilter.userSearchFilterName,
},
};
}
diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json
index 4e2f3199..508bfc80 100644
--- a/src/i18n/locales/de/translation.json
+++ b/src/i18n/locales/de/translation.json
@@ -323,6 +323,10 @@
"remove": "Von der Merkliste entfernen"
},
"unseenNotification": "Aktualisiert",
+ "searchFilter": {
+ "matchedBadge": "Treffer",
+ "matchedBy": "Suchauftrag"
+ },
"imageLoadError": "Bild konnte nicht geladen werden",
"prohibitedImage": "Bild aufgrund von Inhaltsbeschränkungen ausgeblendet",
"similar": {
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 03220b4b..7dd8feff 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -325,6 +325,10 @@
"remove": "Remove from watchlist"
},
"unseenNotification": "Updated",
+ "searchFilter": {
+ "matchedBadge": "Match",
+ "matchedBy": "Search Filter"
+ },
"imageLoadError": "Image could not be loaded",
"prohibitedImage": "Image hidden due to content restrictions",
"similar": {