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:
parent
0b874dbd57
commit
bb10907463
@ -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(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
69
app/requestHandler/use-view-hooks.ts
Normal file
69
app/requestHandler/use-view-hooks.ts
Normal 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]);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user