@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 +2 -1
- package/vtex/actions/analytics/sendEvent.ts +85 -0
- package/vtex/hooks/useCart.ts +1 -1
- package/vtex/inline-loaders/minicart.ts +1 -3
- package/vtex/inline-loaders/productList.ts +11 -4
- package/vtex/inline-loaders/productListingPage.ts +27 -0
- package/vtex/loaders/legacy.ts +1 -1
- package/vtex/manifest.gen.ts +2 -0
- package/vtex/utils/fetch.ts +107 -0
- package/vtex/utils/minicart.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/apps",
|
|
3
|
-
"version": "1.
|
|
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;
|
package/vtex/hooks/useCart.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
+
/** @title Product IDs */
|
|
43
|
+
export interface ProductIDProps {
|
|
39
44
|
ids: string[];
|
|
40
45
|
hideUnavailableItems?: boolean;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
|
-
|
|
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;
|
package/vtex/loaders/legacy.ts
CHANGED
|
@@ -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) => {
|
package/vtex/manifest.gen.ts
CHANGED
|
@@ -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
|
+
}
|
package/vtex/utils/minicart.ts
CHANGED
|
@@ -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")
|
|
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;
|