@decocms/apps 1.1.0 → 1.1.2
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/commerce/sdk/analytics.ts +126 -140
- package/package.json +1 -1
- package/shopify/init.ts +4 -2
- package/shopify/loaders/ProductList.ts +15 -11
- package/shopify/mod.ts +3 -3
- package/shopify/utils/admin/admin.ts +1 -0
- package/vtex/actions/address.ts +13 -4
- package/vtex/actions/profile.ts +18 -6
- package/vtex/client.ts +6 -2
- package/vtex/commerceLoaders.ts +12 -36
- package/vtex/inline-loaders/productListingPage.ts +1 -0
- package/vtex/loaders/autocomplete.ts +38 -38
- package/vtex/middleware.ts +14 -9
- package/vtex/utils/accountLoaders.ts +5 -4
- package/vtex/utils/authHelpers.ts +42 -48
- package/vtex/utils/index.ts +1 -1
- package/vtex/utils/proxy.ts +6 -19
|
@@ -4,162 +4,148 @@
|
|
|
4
4
|
* These are the generic, platform-independent mappers. Sites can wrap them
|
|
5
5
|
* to add custom fields (sellerP, etc.) via the `extend` option.
|
|
6
6
|
*/
|
|
7
|
-
import type { BreadcrumbList, Product } from "../types";
|
|
7
|
+
import type { BreadcrumbList, Product } from "../types/commerce";
|
|
8
8
|
|
|
9
9
|
export interface AnalyticsItem {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
10
|
+
item_id?: string;
|
|
11
|
+
item_name?: string;
|
|
12
|
+
affiliation?: string;
|
|
13
|
+
coupon?: string;
|
|
14
|
+
discount?: number;
|
|
15
|
+
index?: number;
|
|
16
|
+
item_group_id?: string;
|
|
17
|
+
item_url?: string;
|
|
18
|
+
item_brand?: string;
|
|
19
|
+
item_category?: string;
|
|
20
|
+
item_category2?: string;
|
|
21
|
+
item_category3?: string;
|
|
22
|
+
item_category4?: string;
|
|
23
|
+
item_category5?: string;
|
|
24
|
+
item_list_id?: string;
|
|
25
|
+
item_list_name?: string;
|
|
26
|
+
item_variant?: string;
|
|
27
|
+
location_id?: string;
|
|
28
|
+
price?: number;
|
|
29
|
+
quantity: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function mapCategoriesToAnalyticsCategories(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
{} as Record<string, string>,
|
|
42
|
-
);
|
|
33
|
+
export function mapCategoriesToAnalyticsCategories(categories: string[]): Record<string, string> {
|
|
34
|
+
return categories.slice(0, 5).reduce(
|
|
35
|
+
(result, category, index) => {
|
|
36
|
+
result[`item_category${index === 0 ? "" : index + 1}`] = category;
|
|
37
|
+
return result;
|
|
38
|
+
},
|
|
39
|
+
{} as Record<string, string>,
|
|
40
|
+
);
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
export function mapProductCategoryToAnalyticsCategories(category: string): Record<string, string> {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
44
|
+
return category.split(">").reduce(
|
|
45
|
+
(result, cat, index) => {
|
|
46
|
+
result[`item_category${index === 0 ? "" : index}`] = cat.trim();
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
49
|
+
{} as Record<string, string>,
|
|
50
|
+
);
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
export interface MapProductToAnalyticsItemOptions {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
product: Product;
|
|
55
|
+
breadcrumbList?: BreadcrumbList;
|
|
56
|
+
price?: number;
|
|
57
|
+
lowPrice?: number;
|
|
58
|
+
listPrice?: number;
|
|
59
|
+
index?: number;
|
|
60
|
+
quantity?: number;
|
|
61
|
+
coupon?: string;
|
|
62
|
+
/** Extend the result with custom fields (e.g., sellerP, sellerName) */
|
|
63
|
+
extend?: (product: Product, base: AnalyticsItem) => Record<string, unknown>;
|
|
66
64
|
}
|
|
67
65
|
|
|
68
66
|
export function mapProductToAnalyticsItem(opts: MapProductToAnalyticsItemOptions): AnalyticsItem {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return base;
|
|
67
|
+
const {
|
|
68
|
+
product,
|
|
69
|
+
breadcrumbList,
|
|
70
|
+
price,
|
|
71
|
+
lowPrice,
|
|
72
|
+
listPrice,
|
|
73
|
+
index = 0,
|
|
74
|
+
quantity = 1,
|
|
75
|
+
coupon = "",
|
|
76
|
+
extend,
|
|
77
|
+
} = opts;
|
|
78
|
+
|
|
79
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url, sku } = product;
|
|
80
|
+
|
|
81
|
+
const categories = breadcrumbList?.itemListElement
|
|
82
|
+
? mapCategoriesToAnalyticsCategories(
|
|
83
|
+
breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
|
|
84
|
+
)
|
|
85
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
86
|
+
|
|
87
|
+
const base: AnalyticsItem = {
|
|
88
|
+
item_id: productID,
|
|
89
|
+
item_group_id: inProductGroupWithID,
|
|
90
|
+
quantity,
|
|
91
|
+
coupon,
|
|
92
|
+
price: lowPrice,
|
|
93
|
+
index,
|
|
94
|
+
item_variant: sku,
|
|
95
|
+
discount: Number((price && listPrice ? listPrice - price : 0).toFixed(2)),
|
|
96
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
97
|
+
item_brand: product.brand?.name ?? "",
|
|
98
|
+
item_url: url,
|
|
99
|
+
...categories,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (extend) {
|
|
103
|
+
return { ...base, ...extend(product, base) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return base;
|
|
111
107
|
}
|
|
112
108
|
|
|
113
109
|
export interface MapProductToAnalyticsItemListOptions {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
110
|
+
product: Product;
|
|
111
|
+
breadcrumbList?: BreadcrumbList;
|
|
112
|
+
price?: number;
|
|
113
|
+
listPrice?: number;
|
|
114
|
+
index?: number;
|
|
115
|
+
quantity?: number;
|
|
116
|
+
coupon?: string;
|
|
121
117
|
}
|
|
122
118
|
|
|
123
|
-
export function mapProductToAnalyticsItemList(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
quantity,
|
|
156
|
-
coupon,
|
|
157
|
-
price: finalPrice,
|
|
158
|
-
index,
|
|
159
|
-
discount: Number(discount.toFixed(2)),
|
|
160
|
-
item_name: isVariantOf?.name ?? name ?? "",
|
|
161
|
-
item_brand: product.brand?.name ?? "",
|
|
162
|
-
item_url: url,
|
|
163
|
-
...categories,
|
|
164
|
-
};
|
|
119
|
+
export function mapProductToAnalyticsItemList(
|
|
120
|
+
opts: MapProductToAnalyticsItemListOptions,
|
|
121
|
+
): AnalyticsItem {
|
|
122
|
+
const { product, breadcrumbList, price, listPrice, index = 0, quantity = 1, coupon = "" } = opts;
|
|
123
|
+
|
|
124
|
+
const { name, productID, inProductGroupWithID, isVariantOf, url } = product;
|
|
125
|
+
|
|
126
|
+
const categories = breadcrumbList?.itemListElement
|
|
127
|
+
? mapCategoriesToAnalyticsCategories(
|
|
128
|
+
breadcrumbList.itemListElement.map(({ name: n }) => n ?? "").filter(Boolean),
|
|
129
|
+
)
|
|
130
|
+
: mapProductCategoryToAnalyticsCategories(product.category ?? "");
|
|
131
|
+
|
|
132
|
+
const finalPrice = typeof price === "number" ? price : 0;
|
|
133
|
+
const discount =
|
|
134
|
+
typeof listPrice === "number" && typeof price === "number" ? Math.max(0, listPrice - price) : 0;
|
|
135
|
+
|
|
136
|
+
const itemId = inProductGroupWithID ?? isVariantOf?.productGroupID ?? productID;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
item_id: itemId,
|
|
140
|
+
item_group_id: inProductGroupWithID,
|
|
141
|
+
quantity,
|
|
142
|
+
coupon,
|
|
143
|
+
price: finalPrice,
|
|
144
|
+
index,
|
|
145
|
+
discount: Number(discount.toFixed(2)),
|
|
146
|
+
item_name: isVariantOf?.name ?? name ?? "",
|
|
147
|
+
item_brand: product.brand?.name ?? "",
|
|
148
|
+
item_url: url,
|
|
149
|
+
...categories,
|
|
150
|
+
};
|
|
165
151
|
}
|
package/package.json
CHANGED
package/shopify/init.ts
CHANGED
|
@@ -23,8 +23,10 @@ export function initShopify(config: { storeName: string; storefrontAccessToken:
|
|
|
23
23
|
* Initialize Shopify from a blocks map (convenience wrapper).
|
|
24
24
|
* Looks for the "deco-shopify" block and extracts credentials.
|
|
25
25
|
*/
|
|
26
|
-
export function initShopifyFromBlocks(blocks: Record<string,
|
|
27
|
-
const shopifyBlock = blocks["deco-shopify"]
|
|
26
|
+
export function initShopifyFromBlocks(blocks: Record<string, unknown>) {
|
|
27
|
+
const shopifyBlock = blocks["deco-shopify"] as
|
|
28
|
+
| { storeName: string; storefrontAccessToken: string }
|
|
29
|
+
| undefined;
|
|
28
30
|
if (!shopifyBlock) {
|
|
29
31
|
console.warn("[Shopify] No deco-shopify block found.");
|
|
30
32
|
return;
|
|
@@ -37,8 +37,8 @@ export type Props = {
|
|
|
37
37
|
metafields?: Metafield[];
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
const isQueryList = (p:
|
|
41
|
-
typeof p.query === "string" && typeof p.count === "number";
|
|
40
|
+
const isQueryList = (p: QueryProps | CollectionProps): p is QueryProps =>
|
|
41
|
+
"query" in p && typeof p.query === "string" && typeof p.count === "number";
|
|
42
42
|
|
|
43
43
|
export default async function productListLoader(
|
|
44
44
|
expandedProps: Props,
|
|
@@ -52,19 +52,23 @@ export default async function productListLoader(
|
|
|
52
52
|
const metafields = expandedProps.metafields || [];
|
|
53
53
|
const sort = props.sort ?? "";
|
|
54
54
|
|
|
55
|
-
const filters:
|
|
56
|
-
expandedProps.filters?.tags
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const filters: Record<string, unknown>[] = [];
|
|
56
|
+
for (const tag of expandedProps.filters?.tags ?? []) {
|
|
57
|
+
filters.push({ tag });
|
|
58
|
+
}
|
|
59
|
+
for (const productType of expandedProps.filters?.productTypes ?? []) {
|
|
60
|
+
filters.push({ productType });
|
|
61
|
+
}
|
|
62
|
+
for (const productVendor of expandedProps.filters?.productVendors ?? []) {
|
|
63
|
+
filters.push({ productVendor });
|
|
64
|
+
}
|
|
61
65
|
if (expandedProps.filters?.priceMin != null)
|
|
62
66
|
filters.push({ price: { min: expandedProps.filters.priceMin } });
|
|
63
67
|
if (expandedProps.filters?.priceMax != null)
|
|
64
68
|
filters.push({ price: { max: expandedProps.filters.priceMax } });
|
|
65
|
-
expandedProps.filters?.variantOptions
|
|
66
|
-
filters.push({ variantOption })
|
|
67
|
-
|
|
69
|
+
for (const variantOption of expandedProps.filters?.variantOptions ?? []) {
|
|
70
|
+
filters.push({ variantOption });
|
|
71
|
+
}
|
|
68
72
|
|
|
69
73
|
let shopifyProducts: { nodes: ProductShopify[] } | undefined;
|
|
70
74
|
|
package/shopify/mod.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface ShopifyState {
|
|
|
36
36
|
* Returns an AppDefinition or null if required fields are missing.
|
|
37
37
|
*/
|
|
38
38
|
export async function configure(
|
|
39
|
-
block:
|
|
39
|
+
block: Record<string, unknown>,
|
|
40
40
|
resolveSecret: ResolveSecretFn,
|
|
41
41
|
): Promise<AppDefinition<ShopifyState> | null> {
|
|
42
42
|
if (!block?.storeName) return null;
|
|
@@ -48,9 +48,9 @@ export async function configure(
|
|
|
48
48
|
if (!storefrontAccessToken) return null;
|
|
49
49
|
|
|
50
50
|
const config: ShopifyConfig = {
|
|
51
|
-
storeName: block.storeName,
|
|
51
|
+
storeName: block.storeName as string,
|
|
52
52
|
storefrontAccessToken,
|
|
53
|
-
publicUrl: block.publicUrl,
|
|
53
|
+
publicUrl: block.publicUrl as string | undefined,
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
// Bridge: maintain global singleton for backward compat
|
package/vtex/actions/address.ts
CHANGED
|
@@ -215,7 +215,7 @@ export async function updateAddress(
|
|
|
215
215
|
// Handle cookie extraction, postalCode sanitization, and field defaults.
|
|
216
216
|
// ---------------------------------------------------------------------------
|
|
217
217
|
|
|
218
|
-
import {
|
|
218
|
+
import { ensureUnsuffixedAuthCookie, getVtexCookies } from "../utils/cookies";
|
|
219
219
|
|
|
220
220
|
function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
|
|
221
221
|
if (props.postalCode) props.postalCode = props.postalCode.replace(/\D/g, "");
|
|
@@ -224,18 +224,27 @@ function sanitizeAddressInput(props: Record<string, any>): Record<string, any> {
|
|
|
224
224
|
return props;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export async function createAddressFromRequest(
|
|
227
|
+
export async function createAddressFromRequest(
|
|
228
|
+
props: Record<string, any>,
|
|
229
|
+
request: Request,
|
|
230
|
+
): Promise<SavedAddress> {
|
|
228
231
|
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
229
232
|
return createAddress(sanitizeAddressInput(props) as AddressInput, cookie);
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
export async function updateAddressFromRequest(
|
|
235
|
+
export async function updateAddressFromRequest(
|
|
236
|
+
props: Record<string, any>,
|
|
237
|
+
request: Request,
|
|
238
|
+
): Promise<UpdateAddressResult> {
|
|
233
239
|
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
234
240
|
const { addressId, ...fields } = props;
|
|
235
241
|
return updateAddress(addressId, fields, cookie);
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
export async function deleteAddressFromRequest(
|
|
244
|
+
export async function deleteAddressFromRequest(
|
|
245
|
+
props: Record<string, any>,
|
|
246
|
+
request: Request,
|
|
247
|
+
): Promise<DeleteAddressResult> {
|
|
239
248
|
const cookie = ensureUnsuffixedAuthCookie(getVtexCookies(request));
|
|
240
249
|
return deleteAddress(props.addressId, cookie);
|
|
241
250
|
}
|
package/vtex/actions/profile.ts
CHANGED
|
@@ -122,10 +122,10 @@ export async function updateProfile(
|
|
|
122
122
|
// Request-aware wrappers (for COMMERCE_LOADERS / invoke proxy)
|
|
123
123
|
// ---------------------------------------------------------------------------
|
|
124
124
|
|
|
125
|
+
import { getCurrentProfile } from "../loaders/profile";
|
|
125
126
|
import { getVtexCookies } from "../utils/cookies";
|
|
126
|
-
import { updateNewsletterOptIn } from "./newsletter";
|
|
127
127
|
import { deletePaymentToken } from "./misc";
|
|
128
|
-
import {
|
|
128
|
+
import { updateNewsletterOptIn } from "./newsletter";
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Normalize birthDate strings to ISO 8601.
|
|
@@ -148,7 +148,10 @@ function normalizeBirthDate(profile: Record<string, any>): void {
|
|
|
148
148
|
* Update user profile via VTEX IO GraphQL. Handles cookie extraction,
|
|
149
149
|
* birthDate normalization, and undefined-key cleanup.
|
|
150
150
|
*/
|
|
151
|
-
export async function updateProfileFromRequest(
|
|
151
|
+
export async function updateProfileFromRequest(
|
|
152
|
+
props: Record<string, any>,
|
|
153
|
+
request: Request,
|
|
154
|
+
): Promise<any> {
|
|
152
155
|
const { account } = getVtexConfig();
|
|
153
156
|
const cookie = getVtexCookies(request);
|
|
154
157
|
const profile = { ...props };
|
|
@@ -171,17 +174,26 @@ export async function updateProfileFromRequest(props: Record<string, any>, reque
|
|
|
171
174
|
return res.json();
|
|
172
175
|
}
|
|
173
176
|
|
|
174
|
-
export async function newsletterProfileFromRequest(
|
|
177
|
+
export async function newsletterProfileFromRequest(
|
|
178
|
+
props: Record<string, any>,
|
|
179
|
+
request: Request,
|
|
180
|
+
): Promise<any> {
|
|
175
181
|
const cookie = request.headers.get("cookie") ?? "";
|
|
176
182
|
return updateNewsletterOptIn(props.isNewsletterOptIn, props.email, cookie);
|
|
177
183
|
}
|
|
178
184
|
|
|
179
|
-
export async function deletePaymentFromRequest(
|
|
185
|
+
export async function deletePaymentFromRequest(
|
|
186
|
+
props: Record<string, any>,
|
|
187
|
+
request: Request,
|
|
188
|
+
): Promise<any> {
|
|
180
189
|
const cookie = getVtexCookies(request);
|
|
181
190
|
return deletePaymentToken(props.id, cookie);
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
export async function getPasswordLastUpdate(
|
|
193
|
+
export async function getPasswordLastUpdate(
|
|
194
|
+
_props: Record<string, any>,
|
|
195
|
+
request: Request,
|
|
196
|
+
): Promise<string | null> {
|
|
185
197
|
const cookie = getVtexCookies(request);
|
|
186
198
|
const profile = await getCurrentProfile(cookie);
|
|
187
199
|
return profile?.passwordLastUpdate ?? null;
|
package/vtex/client.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { RequestContext } from "@decocms/start/sdk/requestContext";
|
|
7
7
|
import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
|
|
8
|
+
import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
|
|
8
9
|
import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -252,13 +253,16 @@ export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit):
|
|
|
252
253
|
const response = await vtexFetchResponse(path, init);
|
|
253
254
|
const data = (await response.json()) as T;
|
|
254
255
|
|
|
255
|
-
// Forward Set-Cookie headers to RequestContext.responseHeaders
|
|
256
|
-
// (
|
|
256
|
+
// Forward Set-Cookie headers to RequestContext.responseHeaders,
|
|
257
|
+
// but skip VTEX internal IS cookies (managed server-side by the middleware).
|
|
257
258
|
const responseHeaders = getResponseHeaders();
|
|
258
259
|
if (responseHeaders) {
|
|
259
260
|
const setCookies =
|
|
260
261
|
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
|
261
262
|
for (const cookie of setCookies) {
|
|
263
|
+
if (cookie.startsWith(`${SESSION_COOKIE}=`) || cookie.startsWith(`${ANONYMOUS_COOKIE}=`)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
262
266
|
const stripped = cookie.replace(/;\s*domain=[^;]*/gi, "");
|
|
263
267
|
responseHeaders.append("set-cookie", stripped);
|
|
264
268
|
}
|
package/vtex/commerceLoaders.ts
CHANGED
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import { createCachedLoader } from "@decocms/start/sdk/cachedLoader";
|
|
13
13
|
import type { CacheProfileName } from "@decocms/start/sdk/cacheHeaders";
|
|
14
|
-
import vtexProductList from "./inline-loaders/productList";
|
|
15
|
-
import vtexProductListShelf from "./inline-loaders/productListShelf";
|
|
16
14
|
import vtexProductDetailsPage from "./inline-loaders/productDetailsPage";
|
|
15
|
+
import vtexProductList from "./inline-loaders/productList";
|
|
17
16
|
import vtexProductListingPage from "./inline-loaders/productListingPage";
|
|
18
|
-
import
|
|
17
|
+
import vtexProductListShelf from "./inline-loaders/productListShelf";
|
|
19
18
|
import vtexRelatedProducts from "./inline-loaders/relatedProducts";
|
|
19
|
+
import vtexSuggestions from "./inline-loaders/suggestions";
|
|
20
20
|
import vtexWorkflowProducts from "./inline-loaders/workflowProducts";
|
|
21
21
|
import { getCategoryTree } from "./loaders/catalog";
|
|
22
22
|
import { VALID_IS_SORTS } from "./utils/intelligentSearch";
|
|
@@ -51,16 +51,10 @@ function pdpWithSlugFallback(props: any): Promise<any> {
|
|
|
51
51
|
* Extract collection name from PLP product data.
|
|
52
52
|
* Products carry cluster info in additionalProperty with name="cluster".
|
|
53
53
|
*/
|
|
54
|
-
function extractCollectionName(
|
|
55
|
-
result: any,
|
|
56
|
-
collectionId: string,
|
|
57
|
-
): string | null {
|
|
54
|
+
function extractCollectionName(result: any, collectionId: string): string | null {
|
|
58
55
|
if (!result?.products?.length) return null;
|
|
59
56
|
for (const product of result.products) {
|
|
60
|
-
const props =
|
|
61
|
-
product.additionalProperty ||
|
|
62
|
-
product.isVariantOf?.additionalProperty ||
|
|
63
|
-
[];
|
|
57
|
+
const props = product.additionalProperty || product.isVariantOf?.additionalProperty || [];
|
|
64
58
|
for (const prop of props) {
|
|
65
59
|
if (prop.name === "cluster" && prop.propertyID === collectionId) {
|
|
66
60
|
return prop.value || null;
|
|
@@ -147,11 +141,7 @@ export function createVtexCommerceLoaders(
|
|
|
147
141
|
const segments = props.__pagePath.split("/").filter(Boolean);
|
|
148
142
|
const mapValues = mapParam.split(",");
|
|
149
143
|
const facets: Array<{ key: string; value: string }> = [];
|
|
150
|
-
for (
|
|
151
|
-
let i = 0;
|
|
152
|
-
i < Math.min(segments.length, mapValues.length);
|
|
153
|
-
i++
|
|
154
|
-
) {
|
|
144
|
+
for (let i = 0; i < Math.min(segments.length, mapValues.length); i++) {
|
|
155
145
|
const key = mapValues[i].trim();
|
|
156
146
|
const value = decodeURIComponent(segments[i]);
|
|
157
147
|
if (key && value) facets.push({ key, value });
|
|
@@ -175,14 +165,9 @@ export function createVtexCommerceLoaders(
|
|
|
175
165
|
__pageUrl: pageUrl.toString(),
|
|
176
166
|
});
|
|
177
167
|
|
|
178
|
-
const clusterFacet = facets.find(
|
|
179
|
-
(f) => f.key === "productClusterIds",
|
|
180
|
-
);
|
|
168
|
+
const clusterFacet = facets.find((f) => f.key === "productClusterIds");
|
|
181
169
|
if (result && clusterFacet) {
|
|
182
|
-
const collectionName = extractCollectionName(
|
|
183
|
-
result,
|
|
184
|
-
clusterFacet.value,
|
|
185
|
-
);
|
|
170
|
+
const collectionName = extractCollectionName(result, clusterFacet.value);
|
|
186
171
|
if (collectionName) {
|
|
187
172
|
result.breadcrumb = {
|
|
188
173
|
"@type": "BreadcrumbList",
|
|
@@ -238,13 +223,10 @@ export function createVtexCommerceLoaders(
|
|
|
238
223
|
"vtex/loaders/ProductDetailsPage.ts": cachedPDP,
|
|
239
224
|
"vtex/loaders/ProductListingPage.ts": cachedPLP,
|
|
240
225
|
// Category tree
|
|
241
|
-
"vtex/loaders/categories/tree": (props: any) =>
|
|
242
|
-
getCategoryTree(props?.categoryLevels ?? 3),
|
|
226
|
+
"vtex/loaders/categories/tree": (props: any) => getCategoryTree(props?.categoryLevels ?? 3),
|
|
243
227
|
// Commerce passthrough loaders
|
|
244
228
|
"commerce/loaders/navbar.ts": async (props: any) => props.items ?? [],
|
|
245
|
-
"commerce/loaders/product/extensions/detailsPage.ts": async (
|
|
246
|
-
props: any,
|
|
247
|
-
) => {
|
|
229
|
+
"commerce/loaders/product/extensions/detailsPage.ts": async (props: any) => {
|
|
248
230
|
const data = props.data;
|
|
249
231
|
if (data?.product) return data;
|
|
250
232
|
return cachedPDP({ __pagePath: props.__pagePath });
|
|
@@ -274,12 +256,6 @@ export function createVtexCommerceLoaders(
|
|
|
274
256
|
*
|
|
275
257
|
* Returns a new instance each call — sites should cache the reference.
|
|
276
258
|
*/
|
|
277
|
-
export function createCachedPDPLoader(
|
|
278
|
-
|
|
279
|
-
): CommerceLoaderFn {
|
|
280
|
-
return createCachedLoader(
|
|
281
|
-
"vtex/productDetailsPage",
|
|
282
|
-
pdpWithSlugFallback,
|
|
283
|
-
profile,
|
|
284
|
-
);
|
|
259
|
+
export function createCachedPDPLoader(profile: CacheProfileName = "product"): CommerceLoaderFn {
|
|
260
|
+
return createCachedLoader("vtex/productDetailsPage", pdpWithSlugFallback, profile);
|
|
285
261
|
}
|
|
@@ -8,51 +8,51 @@ import { getVtexConfig, intelligentSearch as vtexIS } from "../client";
|
|
|
8
8
|
import { pickSku, toProduct as toSchemaProduct } from "../utils/transform";
|
|
9
9
|
|
|
10
10
|
export interface AutocompleteProps {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
query: string;
|
|
12
|
+
count?: number;
|
|
13
|
+
showSponsored?: boolean;
|
|
14
|
+
placement?: string;
|
|
15
|
+
fuzzy?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface AutocompleteResult {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
searches: Array<{ term: string; count: number; attributes?: any[] }>;
|
|
20
|
+
products: any[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export async function autocompleteSearch(props: AutocompleteProps): Promise<AutocompleteResult> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const query = props.query || "";
|
|
25
|
+
const count = props.count ?? 4;
|
|
26
|
+
if (!query.trim()) return { searches: [], products: [] };
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
try {
|
|
29
|
+
const [suggestionsData, productsData] = await Promise.all([
|
|
30
|
+
vtexIS<{
|
|
31
|
+
searches: Array<{ term: string; count: number; attributes?: any[] }>;
|
|
32
|
+
}>("/autocomplete_suggestions/", { query }),
|
|
33
|
+
vtexIS<{ products: any[] }>("/product_search/", {
|
|
34
|
+
query,
|
|
35
|
+
count: String(count),
|
|
36
|
+
showSponsored: props.showSponsored !== false ? "true" : "false",
|
|
37
|
+
placement: props.placement ?? "top-search",
|
|
38
|
+
fuzzy: props.fuzzy ?? "0",
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
const config = getVtexConfig();
|
|
43
|
+
const baseUrl = config.publicUrl
|
|
44
|
+
? `https://${config.publicUrl}`
|
|
45
|
+
: `https://${config.account}.vtexcommercestable.${config.domain ?? "com.br"}`;
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
return {
|
|
48
|
+
searches: suggestionsData.searches ?? [],
|
|
49
|
+
products: (productsData.products ?? []).slice(0, count).map((p: any) => {
|
|
50
|
+
const sku = pickSku(p);
|
|
51
|
+
return toSchemaProduct(p, sku, 0, { baseUrl, priceCurrency: "BRL" });
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error("[vtex] autocompleteSearch error:", error);
|
|
56
|
+
return { searches: [], products: [] };
|
|
57
|
+
}
|
|
58
58
|
}
|
package/vtex/middleware.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface VtexRequestContext {
|
|
|
63
63
|
isSessionId: string;
|
|
64
64
|
/** Intelligent Search anonymous cookie. */
|
|
65
65
|
isAnonymousId: string;
|
|
66
|
+
/** Whether IS cookies were freshly generated (browser didn't send them). */
|
|
67
|
+
needsISCookies: boolean;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// -------------------------------------------------------------------------
|
|
@@ -125,8 +127,9 @@ export function extractVtexContext(request: Request): VtexRequestContext {
|
|
|
125
127
|
const authToken = extractVtexAuthCookie(cookies);
|
|
126
128
|
const authInfo = authToken ? parseVtexAuthToken(authToken) : null;
|
|
127
129
|
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
+
const existingSessionId = getCookieValue(cookies, SESSION_COOKIE);
|
|
131
|
+
const existingAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE);
|
|
132
|
+
const needsISCookies = !existingSessionId || !existingAnonymousId;
|
|
130
133
|
|
|
131
134
|
return {
|
|
132
135
|
segment,
|
|
@@ -136,8 +139,9 @@ export function extractVtexContext(request: Request): VtexRequestContext {
|
|
|
136
139
|
salesChannel: segment.channel ?? "1",
|
|
137
140
|
regionId: segment.regionId ?? null,
|
|
138
141
|
hasCustomPricing: Boolean(segment.priceTables && segment.priceTables.length > 0),
|
|
139
|
-
isSessionId,
|
|
140
|
-
isAnonymousId,
|
|
142
|
+
isSessionId: existingSessionId ?? generateUUID(),
|
|
143
|
+
isAnonymousId: existingAnonymousId ?? generateUUID(),
|
|
144
|
+
needsISCookies,
|
|
141
145
|
};
|
|
142
146
|
}
|
|
143
147
|
|
|
@@ -177,13 +181,14 @@ export function vtexCacheControl(
|
|
|
177
181
|
// -------------------------------------------------------------------------
|
|
178
182
|
|
|
179
183
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* every user has IS cookies for personalization and analytics.
|
|
184
|
+
* Set Intelligent Search cookies on the response only when the browser
|
|
185
|
+
* doesn't already have them. On subsequent requests where the cookies
|
|
186
|
+
* exist, this is a no-op — keeping the response free of Set-Cookie
|
|
187
|
+
* headers so it remains cacheable at the CDN edge.
|
|
185
188
|
*/
|
|
186
189
|
export function propagateISCookies(ctx: VtexRequestContext, response: Response): void {
|
|
190
|
+
if (!ctx.needsISCookies) return;
|
|
191
|
+
|
|
187
192
|
const maxAge = ONE_YEAR_SECONDS;
|
|
188
193
|
response.headers.append(
|
|
189
194
|
"Set-Cookie",
|
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
* });
|
|
24
24
|
* ```
|
|
25
25
|
*/
|
|
26
|
-
|
|
27
|
-
import {
|
|
28
|
-
import { getCurrentProfile, type Profile } from "../loaders/profile";
|
|
26
|
+
|
|
27
|
+
import { detectDevice } from "@decocms/start/sdk/useDevice";
|
|
29
28
|
import { getUserAddresses, type VtexAddress } from "../loaders/address";
|
|
30
29
|
import { getUserPayments, type Payment } from "../loaders/payment";
|
|
31
|
-
import {
|
|
30
|
+
import { getCurrentProfile, type Profile } from "../loaders/profile";
|
|
31
|
+
import { getUser } from "../loaders/user";
|
|
32
|
+
import { getVtexCookies } from "./cookies";
|
|
32
33
|
|
|
33
34
|
type Device = "mobile" | "tablet" | "desktop";
|
|
34
35
|
|
|
@@ -11,11 +11,11 @@ import { getVtexConfig } from "../client";
|
|
|
11
11
|
const DOMAIN_RE = /;\s*domain=[^;]*/gi;
|
|
12
12
|
|
|
13
13
|
const VTEX_COOKIE_PREFIXES = [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
"vtex_session=",
|
|
15
|
+
"vtex_segment=",
|
|
16
|
+
"VtexIdclientAutCookie",
|
|
17
|
+
"checkout.vtex.com",
|
|
18
|
+
"CheckoutOrderFormOwnership",
|
|
19
19
|
];
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -23,11 +23,11 @@ const VTEX_COOKIE_PREFIXES = [
|
|
|
23
23
|
* Filters out analytics/CF cookies that can cause VTEX 503 errors.
|
|
24
24
|
*/
|
|
25
25
|
export function extractVtexCookiesFromHeader(raw: string): string {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
return raw
|
|
27
|
+
.split(";")
|
|
28
|
+
.map((c) => c.trim())
|
|
29
|
+
.filter((c) => VTEX_COOKIE_PREFIXES.some((prefix) => c.startsWith(prefix)))
|
|
30
|
+
.join("; ");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -35,16 +35,16 @@ export function extractVtexCookiesFromHeader(raw: string): string {
|
|
|
35
35
|
* with the storefront domain instead of the VTEX domain.
|
|
36
36
|
*/
|
|
37
37
|
export function stripCookieDomain(cookies: string[]): string[] {
|
|
38
|
-
|
|
38
|
+
return cookies.map((c) => c.replace(DOMAIN_RE, ""));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** Standard VTEX cookies to expire on logout. */
|
|
42
42
|
export const VTEX_LOGOUT_COOKIES = [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
"checkout.vtex.com=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
44
|
+
"CheckoutOrderFormOwnership=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax",
|
|
45
|
+
"checkout.vtex.com__orderFormId=; Path=/; Max-Age=0",
|
|
46
|
+
"vtex_session=; Path=/; Max-Age=0",
|
|
47
|
+
"vtex_segment=; Path=/; Max-Age=0",
|
|
48
48
|
];
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -52,24 +52,21 @@ export const VTEX_LOGOUT_COOKIES = [
|
|
|
52
52
|
* the Set-Cookie headers (with domain stripped) to expire auth cookies.
|
|
53
53
|
*/
|
|
54
54
|
export async function performVtexLogout(cookies: string): Promise<{ setCookies: string[] }> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const config = getVtexConfig();
|
|
56
|
+
const domain = config.domain ?? "com.br";
|
|
57
|
+
const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
const res = await fetch(logoutUrl, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
headers: { cookie: cookies },
|
|
62
|
+
redirect: "manual",
|
|
63
|
+
});
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
const upstreamCookies = res.headers.getSetCookie?.() ?? [];
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
...VTEX_LOGOUT_COOKIES,
|
|
71
|
-
],
|
|
72
|
-
};
|
|
67
|
+
return {
|
|
68
|
+
setCookies: [...stripCookieDomain(upstreamCookies), ...VTEX_LOGOUT_COOKIES],
|
|
69
|
+
};
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
/**
|
|
@@ -77,21 +74,18 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies:
|
|
|
77
74
|
* Reads the VtexIdclientAutCookie_* cookie from a raw Cookie header.
|
|
78
75
|
*/
|
|
79
76
|
export function parseVtexAuthJwt(rawCookies: string): { email: string; userId: string } | null {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} catch {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
const match = rawCookies.match(/VtexIdclientAutCookie_[^=]+=([^;]+)/);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
const token = match[1];
|
|
81
|
+
const parts = token.split(".");
|
|
82
|
+
if (parts.length < 2) return null;
|
|
83
|
+
const payload = JSON.parse(
|
|
84
|
+
Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"),
|
|
85
|
+
);
|
|
86
|
+
if (!payload.sub) return null;
|
|
87
|
+
return { email: payload.sub, userId: payload.userId ?? "" };
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
97
91
|
}
|
package/vtex/utils/index.ts
CHANGED
package/vtex/utils/proxy.ts
CHANGED
|
@@ -286,17 +286,11 @@ function filterHeadersStrict(headers: Headers): Headers {
|
|
|
286
286
|
* Unlike `proxySetCookie`, this preserves ALL attributes (Max-Age,
|
|
287
287
|
* Expires, SameSite, etc.) which is critical for logout.
|
|
288
288
|
*/
|
|
289
|
-
function rewriteSetCookieDomain(
|
|
290
|
-
from: Headers,
|
|
291
|
-
to: Headers,
|
|
292
|
-
toHostname: string,
|
|
293
|
-
) {
|
|
289
|
+
function rewriteSetCookieDomain(from: Headers, to: Headers, toHostname: string) {
|
|
294
290
|
const raw: string[] =
|
|
295
291
|
typeof from.getSetCookie === "function"
|
|
296
292
|
? from.getSetCookie()
|
|
297
|
-
: (from.get("set-cookie") ?? "")
|
|
298
|
-
.split(/,(?=[^ ]+=)/)
|
|
299
|
-
.filter(Boolean);
|
|
293
|
+
: (from.get("set-cookie") ?? "").split(/,(?=[^ ]+=)/).filter(Boolean);
|
|
300
294
|
|
|
301
295
|
for (const cookie of raw) {
|
|
302
296
|
const rewritten = cookie.replace(/Domain=[^;]*/i, `Domain=${toHostname}`);
|
|
@@ -342,11 +336,8 @@ export function createVtexCheckoutProxy(
|
|
|
342
336
|
const checkoutOrigin = config.checkoutOrigin.startsWith("https://")
|
|
343
337
|
? config.checkoutOrigin
|
|
344
338
|
: `https://${config.checkoutOrigin}`;
|
|
345
|
-
const apiOrigin =
|
|
346
|
-
|
|
347
|
-
`https://${config.account}.vtexcommercestable.${domain}`;
|
|
348
|
-
const myvtexOrigin =
|
|
349
|
-
config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
|
|
339
|
+
const apiOrigin = config.apiOrigin ?? `https://${config.account}.vtexcommercestable.${domain}`;
|
|
340
|
+
const myvtexOrigin = config.myvtexOrigin ?? `https://${config.account}.myvtex.com`;
|
|
350
341
|
|
|
351
342
|
function getOrigin(pathname: string, method: string): string {
|
|
352
343
|
if (
|
|
@@ -375,8 +366,7 @@ export function createVtexCheckoutProxy(
|
|
|
375
366
|
fwd.set("origin", request.headers.get("origin") ?? url.origin);
|
|
376
367
|
|
|
377
368
|
const isCheckoutUI =
|
|
378
|
-
url.pathname.startsWith("/checkout") ||
|
|
379
|
-
url.pathname.startsWith("/account");
|
|
369
|
+
url.pathname.startsWith("/checkout") || url.pathname.startsWith("/account");
|
|
380
370
|
const isLogout = url.pathname.startsWith("/api/vtexid/pub/logout");
|
|
381
371
|
|
|
382
372
|
const init: RequestInit = {
|
|
@@ -399,10 +389,7 @@ export function createVtexCheckoutProxy(
|
|
|
399
389
|
for (const rule of config.expireCookiesOnPaths) {
|
|
400
390
|
if (url.pathname.startsWith(rule.pathPrefix)) {
|
|
401
391
|
for (const name of rule.cookies) {
|
|
402
|
-
resHeaders.append(
|
|
403
|
-
"Set-Cookie",
|
|
404
|
-
`${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`,
|
|
405
|
-
);
|
|
392
|
+
resHeaders.append("Set-Cookie", `${name}=; Path=/; Max-Age=0; Domain=${url.hostname}`);
|
|
406
393
|
}
|
|
407
394
|
}
|
|
408
395
|
}
|