Skip to content
Open
61 changes: 61 additions & 0 deletions vtex/actions/events/productView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { AppContext } from "../../mod.ts";
import { getOrigin, parseCookie } from "../../utils/recommendations.ts";
import type { ProductViewSource } from "../../utils/types.ts";

interface Props {
/**
* @title User ID
* @description The ID of the user who viewed the recommendation.
*/
userId?: string;
/**
* @title Product ID
* @description The ID of the product that was clicked.
*/
Comment on lines +12 to +14

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix product-view prop description typo.

Line 13 says the product was “clicked”, but this loader emits a product-view event.

✏️ Suggested edit
-   * `@description` The ID of the product that was clicked.
+   * `@description` The ID of the product that was viewed.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* @title Product ID
* @description The ID of the product that was clicked.
*/
* `@title` Product ID
* `@description` The ID of the product that was viewed.
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/actions/events/productView.ts` around lines 12 - 14, Update the JSDoc
for the "Product ID" prop in the product-view loader so the description reflects
a view event rather than a click: locate the comment block with `@title` "Product
ID" in productView.ts (the product-view loader) and change the phrase "The ID of
the product that was clicked." to something like "The ID of the product that was
viewed." to match the emitted product-view event.

productId: string;
/**
* @title Source
* @description The source of the product view event.
*/
source: ProductViewSource;
/**
* @description The origin of the recommendation request. E.g: apiexamples/storefront/vtex.recommendation-shelf@2.x
*/
"x-vtex-rec-origin"?: string;
}

export default async function loader(
props: Props,
req: Request,
ctx: AppContext,
) {
const { bff } = ctx;

const origin = getOrigin(req, ctx.account, props["x-vtex-rec-origin"]);
if (!origin) {
throw new Error("x-vtex-rec-origin header is required");
}

const cookies = parseCookie(req.headers);
const userId = props.userId ?? cookies?.userId;

if (!userId) {
throw new Error("userId is required");
}

await bff["POST /api/recommend-bff/v2/events/product-view"]({
an: ctx.account,
}, {
body: {
userId,
product: props.productId,
source: props.source,
},
headers: {
"x-vtex-rec-origin": origin,
"user-agent": req.headers.get("user-agent") || "",
},
});

return { ok: true };
}
59 changes: 59 additions & 0 deletions vtex/actions/events/recommendationClick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AppContext } from "../../mod.ts";
import { getOrigin, parseCookie } from "../../utils/recommendations.ts";

interface Props {
/**
* @title User ID
* @description The ID of the user who viewed the recommendation.
*/
userId?: string;
/**
* @title Product ID
* @description The ID of the product that was clicked.
*/
productId: string;
/**
* @title Correlation ID
* @description The correlation ID of the recommendation request.
*/
correlationId: string;
/**
* @description The origin of the recommendation request. E.g: apiexamples/storefront/vtex.recommendation-shelf@2.x
*/
"x-vtex-rec-origin"?: string;
}

export default async function loader(
props: Props,
req: Request,
ctx: AppContext,
) {
const { bff } = ctx;

const origin = getOrigin(req, ctx.account, props["x-vtex-rec-origin"]);
if (!origin) {
throw new Error("x-vtex-rec-origin header is required");
}

const cookies = parseCookie(req.headers);
const userId = props.userId ?? cookies?.userId;

if (!userId) {
throw new Error("userId is required");
}

await bff["POST /api/recommend-bff/v2/events/recommendation-click"]({
an: ctx.account,
}, {
body: {
correlationId: props.correlationId,
userId,
product: props.productId,
},
headers: {
"x-vtex-rec-origin": origin,
},
});

return { ok: true };
}
59 changes: 59 additions & 0 deletions vtex/actions/events/recommendationView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AppContext } from "../../mod.ts";
import { getOrigin, parseCookie } from "../../utils/recommendations.ts";

interface Props {
/**
* @title User ID
* @description The ID of the user who viewed the recommendation.
*/
userId?: string;
/**
* @title Correlation ID
* @description The correlation ID of the recommendation request.
*/
correlationId: string;
/**
* @title Products
* @description The products SKU IDs that were viewed.
*/
products: string[];
/**
* @description The origin of the recommendation request. E.g: apiexamples/storefront/vtex.recommendation-shelf@2.x
*/
"x-vtex-rec-origin"?: string;
}

export default async function loader(
props: Props,
req: Request,
ctx: AppContext,
) {
const { bff } = ctx;

const origin = getOrigin(req, ctx.account, props["x-vtex-rec-origin"]);
if (!origin) {
throw new Error("x-vtex-rec-origin header is required");
}

const cookies = parseCookie(req.headers);
const userId = props.userId ?? cookies?.userId;

if (!userId) {
throw new Error("userId is required");
}

await bff["POST /api/recommend-bff/v2/events/recommendation-view"]({
an: ctx.account,
}, {
body: {
correlationId: props.correlationId,
userId,
products: props.products,
},
headers: {
"x-vtex-rec-origin": origin,
},
});

return { ok: true };
}
43 changes: 43 additions & 0 deletions vtex/actions/recommendation/startSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getSetCookies } from "std/http/cookie.ts";
import type { AppContext } from "../../mod.ts";
import { proxySetCookie } from "../../utils/cookies.ts";
import { parseCookie } from "../../utils/orderForm.ts";

export default async function action(
_: unknown,
req: Request,
ctx: AppContext,
) {
const { bff } = ctx;
const { orderFormId } = parseCookie(req.headers);

const url = new URL(req.url);
const host = url.host;

const headers = new Headers();
headers.set(
"x-vtex-rec-origin",
`${ctx.account}/storefront/deco.recommendations@1.x`,
);
Comment on lines +21 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Respect caller-provided x-vtex-rec-origin.

The PR contract says callers can send {account}/{source}/{app}, but Lines 21-25 always overwrite that with the default value. That loses source/app attribution for any non-default integration. Prefer the inbound header and only fall back to the default when it is missing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/actions/recommendation/startSession.ts` around lines 21 - 25, The code
unconditionally overwrites the caller-provided x-vtex-rec-origin header; change
it to respect an incoming header first by reading the request header (e.g.,
ctx.request.headers.get('x-vtex-rec-origin') or equivalent) and only set
headers.set('x-vtex-rec-origin',
`${ctx.account}/storefront/deco.recommendations@1.x`) when that incoming value
is missing/empty. Update the logic around the local headers variable in
startSession (where headers is created) to use the existing header value if
present, otherwise fall back to the default constructed from ctx.account and
storefront/deco.recommendations@1.x.

headers.set("x-forwarded-host", host);
headers.set("host", `${ctx.account}.vtexcommercestable.com.br`);

const cookie = req.headers.get("cookie");
if (cookie) {
headers.set("cookie", cookie);
}

const response = await bff["POST /api/recommend-bff/v2/users/start-session"]({
an: ctx.account,
}, {
body: { orderFormId },
headers,
});

const data = await response.json();

console.log(data, getSetCookies(response.headers));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
proxySetCookie(response.headers, ctx.response.headers, req.url);

return data;
}
169 changes: 169 additions & 0 deletions vtex/loaders/recommendations/productList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Product } from "../../../commerce/types.ts";
import { AppContext } from "../../mod.ts";
import { getOrigin, parseCookie } from "../../utils/recommendations.ts";
import { getSegmentFromBag, withSegmentCookie } from "../../utils/segment.ts";
import { toProduct } from "../../utils/transform.ts";
import {
CampaignType,
LegacyProduct,
ProductListToId,
} from "../../utils/types.ts";
import { getFirstItemAvailable } from "../legacy/productListingPage.ts";

type CampaignTypeLabel =
| "Best sellers"
| "Personalized recommendations"
| "Similar items"
| "Cross-sell"
| "Cart-based recommendations"
| "Last seen"
| "Recent interactions"
| "Visual similarity"
| "Search-based recommendations"
| "Next interaction";

const campaignTypeMap: Record<CampaignTypeLabel, CampaignType> = {
"Best sellers": "rec-top-items-v2",
"Personalized recommendations": "rec-persona-v2",
"Similar items": "rec-similar-v2",
"Cross-sell": "rec-cross-v2",
"Cart-based recommendations": "rec-cart-v2",
"Last seen": "rec-last-v2",
"Recent interactions": "rec-interactions-v2",
"Visual similarity": "rec-visual-v2",
"Search-based recommendations": "rec-search-v2",
"Next interaction": "rec-next-v2",
};

interface Props {
/**
* @title Campaign Type
* @description The type of campaign to fetch recommendations for.
*/
campaignType: CampaignTypeLabel;
/**
* @title Campaign ID
* @description Contact https://supporticket.vtex.com/support to get the campaign ID.
*/
campaignId: string;
/**
* @title Products
* @description List of product IDs for context-based recommendations. For similar items and cross-sell, send only one product ID. For cart recommendations, send all product IDs in the user's cart.
*/
products?: ProductListToId;
/**
* @title Zip Code
* @description Zip code for location-based recommendations. If not provided, it is extracted from the segment cookie.
*/
zipCode?: string;
/**
* @title Pickup Point
* @description Pickup point identifier for location-based recommendations. If not provided, it is extracted from the segment cookie.
*/
pickupPoint?: string;
/**
* @title Region ID
* @description Region identifier for location-based recommendations. If not provided, it is extracted from the segment cookie.
*/
regionId?: string;
/**
* @ignore true
*/
"x-vtex-rec-origin"?: string;
}

/**
* @title VTEX Recommendations Product List
* @description Get a list of products from the VTEX Recommendations API
*/
export default async function loader(
props: Props,
req: Request,
ctx: AppContext,
): Promise<Product[] | null> {
try {
const { bff, account } = ctx;

const origin = getOrigin(req, ctx.account, props["x-vtex-rec-origin"]);
const segment = getSegmentFromBag(ctx);
const cookies = parseCookie(req.headers);

let userId = cookies?.userId;

if (ctx.advancedConfigs?.autoStartRecommendationSession && !userId) {
const response = await ctx.invoke.vtex.actions.recommendation
.startSession();
userId = response.recommendationsUserId;
}

const campaignType = campaignTypeMap[props.campaignType];
const campaignVrn =
`vrn:recommendations:${account}:${campaignType}:${props.campaignId}`;

// From vtex docs:
// "For similar items and cross-sell, send only one product ID.
// For cart recommendations, send all product IDs in the user's cart."
const products =
campaignType === "rec-cross-v2" || campaignType === "rec-similar-v2"
? [props.products?.[0]]
: props.products;

const productIds = products?.filter(Boolean).join(",");

Comment on lines +107 to +113

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate required product context for cross/similar campaigns.

Lines 106-112 allow missing product IDs for rec-cross-v2 / rec-similar-v2, which can send an empty products parameter and cause avoidable BFF failures.

🛡️ Suggested fix
-    const products =
-      campaignType === "rec-cross-v2" || campaignType === "rec-similar-v2"
-        ? [props.products?.[0]]
-        : props.products;
+    const requiresSingleProduct =
+      campaignType === "rec-cross-v2" || campaignType === "rec-similar-v2";
+
+    if (requiresSingleProduct && !props.products?.[0]) {
+      throw new Error(
+        `campaignType "${props.campaignType}" requires one product ID`,
+      );
+    }
+
+    const products = requiresSingleProduct
+      ? [props.products![0]]
+      : props.products;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const products =
campaignType === "rec-cross-v2" || campaignType === "rec-similar-v2"
? [props.products?.[0]]
: props.products;
const productIds = products?.filter(Boolean).join(",");
const requiresSingleProduct =
campaignType === "rec-cross-v2" || campaignType === "rec-similar-v2";
if (requiresSingleProduct && !props.products?.[0]) {
throw new Error(
`campaignType "${props.campaignType}" requires one product ID`,
);
}
const products = requiresSingleProduct
? [props.products![0]]
: props.products;
const productIds = products?.filter(Boolean).join(",");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/loaders/recommendations/productList.ts` around lines 106 - 112, For
rec-cross-v2 and rec-similar-v2 ensure a source product exists before building
the products/productIds: check campaignType and validate props.products?.[0] is
present; if missing, bail out (throw, return an error response, or skip calling
the BFF) instead of constructing products = [props.products?.[0]] which yields
undefined and productIds = "". Update the logic around the products and
productIds variables (referencing campaignType, products, productIds, and
props.products) to perform this guard and handle the missing-product case
explicitly.

const response = await bff["GET /api/recommend-bff/v2/recommendations"]({
an: account,
campaignVrn,
pickupPoint: props.pickupPoint,
regionId: props.regionId,
zipCode: props.zipCode,
userId,
products: productIds,
salesChannel: segment?.payload?.channel || ctx.salesChannel,
}, {
headers: { ...withSegmentCookie(segment), "x-vtex-rec-origin": origin },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check vtex/utils/segment.ts for withSegmentCookie definition and Headers usage
echo "=== Checking vtex/utils/segment.ts ==="
rg -n -C3 'export const withSegmentCookie|new Headers\(|return h' vtex/utils/segment.ts

echo ""
echo "=== Checking vtex/loaders/recommendations/productList.ts line 123 ==="
rg -n -C3 '\.\.\.withSegmentCookie\(segment\)' vtex/loaders/recommendations/productList.ts

Repository: deco-cx/apps

Length of output: 858


Segment cookie is dropped when spreading Headers into a plain object.

Line 123 spreads withSegmentCookie(segment) (which returns a Headers instance) into a plain object literal. A Headers object doesn't preserve its entries when spread into {} because it's a Web API object with custom iteration semantics, not a plain object with enumerable properties. This silently drops the segment cookie and breaks segment-aware recommendations.

Fix
-    const response = await bff["GET /api/recommend-bff/v2/recommendations"]({
+    const response = await bff["GET /api/recommend-bff/v2/recommendations"]({
       an: account,
       campaignVrn,
       pickupPoint: props.pickupPoint,
       regionId: props.regionId,
       zipCode: props.zipCode,
       userId,
       products: productIds,
       salesChannel: segment?.payload?.channel || ctx.salesChannel,
     }, {
-      headers: { ...withSegmentCookie(segment), "x-vtex-rec-origin": origin },
+      headers: withSegmentCookie(
+        segment,
+        new Headers({ "x-vtex-rec-origin": origin }),
+      ),
     }).then((res) => res.json());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/loaders/recommendations/productList.ts` at line 123, The headers spread
is dropping the segment cookie because withSegmentCookie(segment) returns a
Headers instance which doesn't spread into a plain object; change the merge to
preserve entries by converting the Headers to a plain object (e.g.
Object.fromEntries(withSegmentCookie(segment))) or build a Headers object and
call .set("x-vtex-rec-origin", origin) so the segment cookie from
withSegmentCookie(segment) is retained; update the code that currently uses
headers: { ...withSegmentCookie(segment), "x-vtex-rec-origin": origin } to one
of these approaches (refer to withSegmentCookie and the headers property in
productList.ts).

}).then((res) => res.json());
Comment on lines +114 to +128

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how other VTEX loaders handle segment cookie
echo "=== How withSegmentCookie is used elsewhere ==="
rg -n "withSegmentCookie" --type=ts -C3 vtex/loaders/

echo ""
echo "=== Check segment utility implementation ==="
cat vtex/utils/segment.ts | head -60

Repository: deco-cx/apps

Length of output: 15921


🏁 Script executed:

cat vtex/utils/segment.ts | sed -n '30,120p'

Repository: deco-cx/apps

Length of output: 2752


🏁 Script executed:

rg -n "withSegmentCookie" vtex/utils/segment.ts -A 20

Repository: deco-cx/apps

Length of output: 509


🏁 Script executed:

sed -n '100,140p' vtex/loaders/recommendations/productList.ts

Repository: deco-cx/apps

Length of output: 1413


🏁 Script executed:

sed -n '1,115p' vtex/loaders/recommendations/productList.ts | tail -40

Repository: deco-cx/apps

Length of output: 1486


🏁 Script executed:

sed -n '1,20p' vtex/loaders/recommendations/productList.ts

Repository: deco-cx/apps

Length of output: 696


Use withSegmentCookie() to pass segment context to the recommendations API.

The segment variable is available from getSegmentFromBag(ctx) but is only used for salesChannel and currencyCode values. The cookie header should use withSegmentCookie(segment) instead of the raw request cookie, consistent with all other VTEX loaders (intelligentSearch, legacy, categories). This ensures the recommendations API receives the proper vtex_segment token for contextualized results.

Import withSegmentCookie from ../../utils/segment.ts and update the headers:

headers: {
  ...withSegmentCookie(segment),
  "x-vtex-rec-origin": origin,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/loaders/recommendations/productList.ts` around lines 114 - 128, Replace
the raw cookie header when calling the recommendations BFF so the request
includes the VTEX segment token: import withSegmentCookie from
../../utils/segment.ts, ensure you use the `segment` value obtained from
`getSegmentFromBag(ctx)`, and update the headers passed to bff["GET
/api/recommend-bff/v2/recommendations"] to spread `withSegmentCookie(segment)`
and include "x-vtex-rec-origin": origin instead of using
req.headers.get("cookie") directly; keep the rest of the request payload
(userId, products, salesChannel, etc.) unchanged.


const baseUrl = new URL(req.url).origin;

return (response.products as unknown as LegacyProduct[]).map((product) =>
toProduct(
product,
product.items.find(getFirstItemAvailable) ?? product.items[0],
0,
{
baseUrl,
priceCurrency: segment?.payload?.currencyCode ?? "BRL",
isVariantOfAdditionalProperty: [
{
"@type": "PropertyValue",
name: "correlationId",
value: response.correlationId,
valueReference: "RECOMMENDATION",
},
{
"@type": "PropertyValue",
name: "campaignId",
value: response.campaign?.id,
valueReference: "RECOMMENDATION",
},
{
"@type": "PropertyValue",
name: "campaignTitle",
value: response.campaign?.title,
valueReference: "RECOMMENDATION",
},
{
"@type": "PropertyValue",
name: "campaignType",
value: response.campaign?.type,
valueReference: "RECOMMENDATION",
},
],
},
)
);
Comment on lines +132 to +168

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add defensive check for response.products before mapping.

The code assumes response.products exists and is iterable. If the BFF returns an error response or a malformed payload, calling .map() on undefined/non-array will throw a TypeError.

🛡️ Suggested fix
     const baseUrl = new URL(req.url).origin;

+    if (!Array.isArray(response.products)) {
+      console.warn("BFF returned no products or invalid response", response);
+      return null;
+    }
+
     return (response.products as unknown as LegacyProduct[]).map((product) =>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vtex/loaders/recommendations/productList.ts` around lines 132 - 168, The
mapping over response.products can throw if products is undefined or not an
array; update the product list loader to defensively handle this by checking
response.products before mapping (e.g., ensure Array.isArray(response.products)
and fallback to an empty array), then call .map(...) using the same
toProduct(...) call and getFirstItemAvailable helper; ensure the code still
returns an empty array or appropriate default when products is missing so
downstream code doesn't receive undefined.

} catch (error) {
console.error(error);
return null;
}
}
Loading
Loading