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 <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-06-29 13:57:04 +03:30
parent 0b874dbd57
commit bb10907463
2 changed files with 74 additions and 0 deletions

View File

@ -37,6 +37,7 @@ import {
} from "~/utils/RequestHandler"; } from "~/utils/RequestHandler";
import { useAddToCart } from "~/requestHandler/use-cart-hooks"; import { useAddToCart } from "~/requestHandler/use-cart-hooks";
import { useCreateChatThread } from "~/requestHandler/use-chat-hooks"; import { useCreateChatThread } from "~/requestHandler/use-chat-hooks";
import { useRecordProductView } from "~/requestHandler/use-view-hooks";
import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers"; import { findVariantId, formatPrice, safeGoBack } from "~/utils/helpers";
import { useToast } from "~/hooks/use-toast"; import { useToast } from "~/hooks/use-toast";
import { useRootData } from "~/hooks/use-root-data"; import { useRootData } from "~/hooks/use-root-data";
@ -76,6 +77,10 @@ export const ProductDetailView = memo(function ProductDetailView({
setProductActiveStatus(product.isActive || false); setProductActiveStatus(product.isActive || false);
}, [product.isActive]); }, [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 // Memoize available colors and sizes
const { availableColors, availableSizes } = useMemo( const { availableColors, availableSizes } = useMemo(
() => ({ () => ({

View File

@ -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<string | null>(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]);
}