@decocms/apps 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -29,6 +29,7 @@
29
29
  "./vtex/types": "./vtex/types.ts",
30
30
  "./vtex/actions": "./vtex/actions/index.ts",
31
31
  "./vtex/actions/*": "./vtex/actions/*.ts",
32
+ "./vtex/actions/analytics/*": "./vtex/actions/analytics/*.ts",
32
33
  "./vtex/loaders": "./vtex/loaders/index.ts",
33
34
  "./vtex/loaders/*": "./vtex/loaders/*.ts",
34
35
  "./vtex/utils": "./vtex/utils/index.ts",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * VTEX Intelligent Search analytics event.
3
+ *
4
+ * Reads the IS session/anonymous cookies from the incoming request and POSTs
5
+ * an event to the IS event-api. The configured account is resolved via
6
+ * {@link getVtexConfig}.
7
+ *
8
+ * @see https://developers.vtex.com/docs/api-reference/intelligent-search-api#post-/event-api/v1/-account-/event
9
+ */
10
+
11
+ import { getVtexConfig } from "../../client";
12
+ import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "../../utils/intelligentSearch";
13
+
14
+ export type Props =
15
+ | {
16
+ type: "session.ping";
17
+ url: string;
18
+ }
19
+ | {
20
+ type: "page.cart";
21
+ products: { productId: string; quantity: number }[];
22
+ }
23
+ | {
24
+ type: "page.empty_cart";
25
+ // Empty array would serialize as an invalid JSON schema, so accept anything.
26
+ products: unknown;
27
+ }
28
+ | {
29
+ type: "page.confirmation";
30
+ order: string;
31
+ products: { productId: string; quantity: number; price: number }[];
32
+ }
33
+ | {
34
+ type: "search.click";
35
+ position: number;
36
+ text: string;
37
+ productId: string;
38
+ url: string;
39
+ }
40
+ | {
41
+ type: "search.query";
42
+ url: string;
43
+ text: string;
44
+ misspelled: boolean;
45
+ match: number;
46
+ operator: string;
47
+ locale: string;
48
+ };
49
+
50
+ const readCookie = (cookieHeader: string, name: string): string | undefined => {
51
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
53
+ return match?.[1];
54
+ };
55
+
56
+ /**
57
+ * @title Send Analytics Event
58
+ * @description POST a VTEX Intelligent Search analytics event for the current session.
59
+ */
60
+ const action = async (props: Props, req: Request): Promise<null> => {
61
+ const { account } = getVtexConfig();
62
+ const cookieHeader = req.headers.get("cookie") ?? "";
63
+ const session = readCookie(cookieHeader, SESSION_COOKIE);
64
+ const anonymous = readCookie(cookieHeader, ANONYMOUS_COOKIE);
65
+
66
+ if (!session || !anonymous) {
67
+ throw new Error("Missing IS Cookies");
68
+ }
69
+
70
+ const url = `https://sp.vtex.com/event-api/v1/${account}/event`;
71
+ await fetch(url, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({
75
+ ...props,
76
+ session,
77
+ anonymous,
78
+ agent: req.headers.get("user-agent") || "deco-sites/apps",
79
+ }),
80
+ });
81
+
82
+ return null;
83
+ };
84
+
85
+ export default action;
@@ -26,8 +26,8 @@
26
26
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27
27
  import { useMemo } from "react";
28
28
  import type { Minicart } from "../../commerce/types/commerce";
29
- import { vtexOrderFormToMinicart } from "../utils/minicart";
30
29
  import type { OrderForm, OrderFormItem } from "../types";
30
+ import { vtexOrderFormToMinicart } from "../utils/minicart";
31
31
 
32
32
  /** Re-exported from `vtex/types` for back-compat. New code should import directly. */
33
33
  export type { OrderForm } from "../types";
@@ -40,9 +40,7 @@ function readOrderFormIdFromRequest(): string | undefined {
40
40
  const ctx = RequestContext.current;
41
41
  const cookieHeader = ctx?.request.headers.get("cookie");
42
42
  if (!cookieHeader) return undefined;
43
- const match = cookieHeader.match(
44
- new RegExp(`(?:^|;\\s*)${ORDER_FORM_COOKIE}=([^;]+)`),
45
- );
43
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${ORDER_FORM_COOKIE}=([^;]+)`));
46
44
  return match?.[1] ? decodeURIComponent(match[1]) : undefined;
47
45
  }
48
46
 
@@ -20,28 +20,35 @@ export interface ProductListProps {
20
20
  hideUnavailableItems?: boolean;
21
21
  }
22
22
 
23
- interface CollectionProps {
23
+ /** @title Collection ID */
24
+ export interface CollectionProps {
25
+ /** VTEX product cluster id (e.g. `"150"`). */
24
26
  collection: string;
25
27
  count?: number;
26
28
  sort?: string;
27
29
  hideUnavailableItems?: boolean;
28
30
  }
29
31
 
30
- interface QueryProps {
32
+ /** @title Keyword Search */
33
+ export interface QueryProps {
31
34
  query: string;
32
35
  count?: number;
33
36
  sort?: string;
37
+ /** Pass either a raw IS fuzzy value (`"0" | "1" | "auto"`) or call `mapLabelledFuzzyToFuzzy()`. */
34
38
  fuzzy?: string;
35
39
  hideUnavailableItems?: boolean;
36
40
  }
37
41
 
38
- interface ProductIDProps {
42
+ /** @title Product IDs */
43
+ export interface ProductIDProps {
39
44
  ids: string[];
40
45
  hideUnavailableItems?: boolean;
41
46
  }
42
47
 
43
- interface FacetsProps {
48
+ /** @title Advanced Facets */
49
+ export interface FacetsProps {
44
50
  query?: string;
51
+ /** Facets path (e.g. `category-1/moda-feminina/category-2/calcados`). */
45
52
  facets: string;
46
53
  count?: number;
47
54
  sort?: string;
@@ -14,6 +14,33 @@ export interface SelectedFacet {
14
14
  value: string;
15
15
  }
16
16
 
17
+ /**
18
+ * Friendly fuzzy labels for CMS UIs. Translate to the raw IS API value via
19
+ * {@link mapLabelledFuzzyToFuzzy} before passing into a loader's `fuzzy` field.
20
+ */
21
+ export type LabelledFuzzy = "automatic" | "disabled" | "enabled";
22
+
23
+ /**
24
+ * Translate a friendly fuzzy label to the value the VTEX Intelligent Search
25
+ * API expects. Returns `undefined` when the label is omitted so callers can
26
+ * skip the param entirely.
27
+ *
28
+ * @example
29
+ * intelligentSearch({ fuzzy: mapLabelledFuzzyToFuzzy(props.fuzzy) })
30
+ */
31
+ export const mapLabelledFuzzyToFuzzy = (label?: LabelledFuzzy): "0" | "1" | "auto" | undefined => {
32
+ switch (label) {
33
+ case "automatic":
34
+ return "auto";
35
+ case "enabled":
36
+ return "1";
37
+ case "disabled":
38
+ return "0";
39
+ default:
40
+ return undefined;
41
+ }
42
+ };
43
+
17
44
  export interface PLPProps {
18
45
  query?: string;
19
46
  count?: number;
@@ -305,7 +305,7 @@ const getTerm = (path: string, map: string) => {
305
305
  return mapSegments.includes("priceFrom") ? formatPriceFromPathToFacet(term) : term;
306
306
  };
307
307
 
308
- const getFirstItemAvailable = (item: LegacyItem) =>
308
+ export const getFirstItemAvailable = (item: LegacyItem) =>
309
309
  !!item?.sellers?.find((s) => s.commertialOffer?.AvailableQuantity > 0);
310
310
 
311
311
  const getTermFallback = (url: URL, isPage: boolean, hasFilters: boolean) => {
@@ -2,6 +2,7 @@
2
2
  // This file is checked into source control and updated via: npm run generate:manifests
3
3
 
4
4
  import * as actions_address from "./actions/address";
5
+ import * as actions_analytics_sendEvent from "./actions/analytics/sendEvent";
5
6
  import * as actions_auth from "./actions/auth";
6
7
  import * as actions_checkout from "./actions/checkout";
7
8
  import * as actions_masterData from "./actions/masterData";
@@ -59,6 +60,7 @@ const manifest = {
59
60
  },
60
61
  actions: {
61
62
  "vtex/actions/address": actions_address,
63
+ "vtex/actions/analytics/sendEvent": actions_analytics_sendEvent,
62
64
  "vtex/actions/auth": actions_auth,
63
65
  "vtex/actions/checkout": actions_checkout,
64
66
  "vtex/actions/masterData": actions_masterData,
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Generic VTEX fetch helpers.
3
+ *
4
+ * These are the canonical primitives for ad-hoc VTEX API calls in apps-start
5
+ * (custom path-resolution loaders, sitemap loaders, custom analytics, etc.).
6
+ * For typed catalog/IS/checkout calls prefer the {@link import("../client").vtexFetch}
7
+ * client which is wired into the configured account.
8
+ *
9
+ * Provides:
10
+ * - {@link fetchSafe} — fetch wrapper that throws {@link HttpError} on non-2xx
11
+ * - {@link fetchAPI} — same, parsed to JSON
12
+ *
13
+ * URLs are sanitized for known XSS-prone query params (utm_*, ft, map) before
14
+ * dispatch. This mirrors the security posture VTEX storefronts carry across
15
+ * the platform.
16
+ */
17
+
18
+ type CachingMode = "stale-while-revalidate";
19
+
20
+ type DecoInit = {
21
+ cache: CachingMode;
22
+ cacheTtlByStatus?: Array<{ from: number; to: number; ttl: number }>;
23
+ };
24
+
25
+ export type DecoRequestInit = RequestInit & { deco?: DecoInit };
26
+
27
+ export class HttpError extends Error {
28
+ readonly status: number;
29
+ readonly response: Response;
30
+
31
+ constructor(response: Response) {
32
+ super(`HTTP ${response.status} ${response.statusText} — ${response.url}`);
33
+ this.name = "HttpError";
34
+ this.status = response.status;
35
+ this.response = response;
36
+ }
37
+ }
38
+
39
+ const removeNonLatin1Chars = (str: string): string =>
40
+ // eslint-disable-next-line no-control-regex
41
+ str.replace(/[^\x00-\xFF]/g, "");
42
+
43
+ const removeScriptChars = (str: string): string => str.replace(/[<>]/g, "");
44
+
45
+ const QS_TO_REMOVE_PLUS = ["utm_campaign", "utm_medium", "utm_source", "map"];
46
+ const QS_TO_REPLACE_PLUS = ["ft"];
47
+
48
+ const sanitizeUrl = (input: string | URL | Request): string | Request | URL => {
49
+ let url: URL;
50
+
51
+ if (typeof input === "string") {
52
+ try {
53
+ url = new URL(input);
54
+ } catch {
55
+ return input;
56
+ }
57
+ } else if (input instanceof URL) {
58
+ url = input;
59
+ } else {
60
+ return input;
61
+ }
62
+
63
+ for (const key of QS_TO_REMOVE_PLUS) {
64
+ if (!url.searchParams.has(key)) continue;
65
+ const values = url.searchParams.getAll(key);
66
+ const cleaned = values.map((v) => removeScriptChars(removeNonLatin1Chars(v))).filter(Boolean);
67
+ url.searchParams.delete(key);
68
+ for (const v of cleaned) url.searchParams.append(key, v);
69
+ }
70
+
71
+ for (const key of QS_TO_REPLACE_PLUS) {
72
+ if (!url.searchParams.has(key)) continue;
73
+ const values = url.searchParams.getAll(key);
74
+ const cleaned = values.map((v) => encodeURIComponent(v.trim()));
75
+ url.searchParams.delete(key);
76
+ for (const v of cleaned) url.searchParams.append(key, v);
77
+ }
78
+
79
+ return url.toString();
80
+ };
81
+
82
+ /**
83
+ * Fetch wrapper that throws {@link HttpError} on non-2xx responses and
84
+ * sanitizes URL query strings.
85
+ */
86
+ export async function fetchSafe(
87
+ input: string | URL | Request,
88
+ init?: DecoRequestInit,
89
+ ): Promise<Response> {
90
+ const sanitized = sanitizeUrl(input);
91
+ const response = await fetch(sanitized as RequestInfo, init);
92
+ if (!response.ok) {
93
+ throw new HttpError(response);
94
+ }
95
+ return response;
96
+ }
97
+
98
+ /**
99
+ * Fetch wrapper that parses the response as JSON. Throws on non-2xx.
100
+ */
101
+ export async function fetchAPI<T = unknown>(
102
+ input: string | URL | Request,
103
+ init?: DecoRequestInit,
104
+ ): Promise<T> {
105
+ const response = await fetchSafe(input, init);
106
+ return response.json() as Promise<T>;
107
+ }
@@ -109,7 +109,9 @@ export function vtexOrderFormToMinicart(
109
109
  const discountsRaw = findTotalizer(totalizers, "Discounts");
110
110
  const discounts = Math.abs(fromCents(discountsRaw));
111
111
  const shippingRaw = findTotalizer(totalizers, "Shipping");
112
- const shipping = totalizers?.some((t) => t.id === "Shipping") ? fromCents(shippingRaw) : undefined;
112
+ const shipping = totalizers?.some((t) => t.id === "Shipping")
113
+ ? fromCents(shippingRaw)
114
+ : undefined;
113
115
  const total = fromCents(orderForm.value);
114
116
 
115
117
  const coupon = orderForm.marketingData?.coupon;