From bb1090746304ec1f97875d5c1492d1a87aba6528 Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Mon, 29 Jun 2026 13:57:04 +0330 Subject: [PATCH] feat(product): record product views for recommendations Fire a fire-and-forget view event when an authenticated, non-owner user opens a product page (deduped per product), POSTing to the new /api/engagement/v1/views/ endpoint. Silent on failure so tracking never disrupts browsing. Co-Authored-By: Claude Opus 4.8 --- app/components/product/ProductDetailView.tsx | 5 ++ app/requestHandler/use-view-hooks.ts | 69 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 app/requestHandler/use-view-hooks.ts diff --git a/app/components/product/ProductDetailView.tsx b/app/components/product/ProductDetailView.tsx index 7eddf72..334c564 100644 --- a/app/components/product/ProductDetailView.tsx +++ b/app/components/product/ProductDetailView.tsx @@ -37,6 +37,7 @@ import { } from "~/utils/RequestHandler"; import { useAddToCart } from "~/requestHandler/use-cart-hooks"; import { useCreateChatThread } from "~/requestHandler/use-chat-hooks"; +import { useRecordProductView } from "~/requestHandler/use-view-hooks"; import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers"; import { useToast } from "~/hooks/use-toast"; import { useRootData } from "~/hooks/use-root-data"; @@ -76,6 +77,10 @@ export const ProductDetailView = memo(function ProductDetailView({ setProductActiveStatus(product.isActive || false); }, [product.isActive]); + // Record a product view for the recommendation system (skip the owner's own + // product; only tracks authenticated users, deduped per product). + useRecordProductView(product.id, { enabled: !isOwner }); + // Memoize available colors and sizes const { availableColors, availableSizes } = useMemo( () => ({ diff --git a/app/requestHandler/use-view-hooks.ts b/app/requestHandler/use-view-hooks.ts new file mode 100644 index 0000000..9c53647 --- /dev/null +++ b/app/requestHandler/use-view-hooks.ts @@ -0,0 +1,69 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { useAuthToken } from "../hooks/use-root-data"; +import { getApiBaseUrl } from "../utils/api-config"; + +/** + * Records that the authenticated user viewed a product. Used as a signal for + * the recommendation system (alongside bookmarks, follows, orders, semantic + * proximity). Fire-and-forget: failures are swallowed so tracking never + * disrupts the browsing experience. + * + * NOTE: this hits /api/engagement/v1/views/ via a direct fetch because the + * endpoint is newer than the generated OpenAPI client. Swap to the generated + * EngagementApi method once the client is regenerated. + */ +export function useTrackProductView() { + const token = useAuthToken(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (productId: string) => { + if (!token || !productId) return { skipped: true }; + + const res = await fetch(`${getApiBaseUrl()}/api/engagement/v1/views/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ productId }), + }); + + if (!res.ok) { + throw new Error(`Failed to track view: ${res.status}`); + } + return res.json(); + }, + onSuccess: () => { + // Keep "recently viewed" fresh if/when it's rendered. + queryClient.invalidateQueries({ queryKey: ["recentlyViewedProducts"] }); + }, + // View tracking is best-effort; never surface errors to the user. + onError: () => {}, + }); +} + +/** + * Fires a single product-view event when a product page is opened. Dedupes per + * productId so React strict-mode double-mounts / re-renders don't double-count + * within the same mount, and skips when not authenticated. + */ +export function useRecordProductView( + productId: string | undefined, + options?: { enabled?: boolean } +) { + const token = useAuthToken(); + const { mutate } = useTrackProductView(); + const lastTrackedRef = useRef(null); + const enabled = options?.enabled ?? true; + + useEffect(() => { + if (!enabled) return; + if (!token || !productId) return; + if (lastTrackedRef.current === productId) return; + + lastTrackedRef.current = productId; + mutate(productId); + }, [productId, token, enabled, mutate]); +}