@decocms/apps-vtex 7.2.0
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 +67 -0
- package/src/README.md +6 -0
- package/src/__tests__/client-segment-cookie.test.ts +255 -0
- package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
- package/src/actions/address.ts +250 -0
- package/src/actions/analytics/sendEvent.ts +86 -0
- package/src/actions/auth.ts +333 -0
- package/src/actions/checkout.ts +522 -0
- package/src/actions/index.ts +11 -0
- package/src/actions/masterData.ts +168 -0
- package/src/actions/misc.ts +188 -0
- package/src/actions/newsletter.ts +105 -0
- package/src/actions/orders.ts +34 -0
- package/src/actions/profile.ts +201 -0
- package/src/actions/session.ts +85 -0
- package/src/actions/trigger.ts +43 -0
- package/src/actions/wishlist.ts +114 -0
- package/src/client.ts +667 -0
- package/src/commerceLoaders.ts +261 -0
- package/src/hooks/__tests__/createUseCart.test.ts +184 -0
- package/src/hooks/__tests__/createUseUser.test.ts +48 -0
- package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
- package/src/hooks/createUseCart.ts +360 -0
- package/src/hooks/createUseUser.ts +153 -0
- package/src/hooks/createUseWishlist.ts +242 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/useAutocomplete.ts +83 -0
- package/src/hooks/useCart.ts +243 -0
- package/src/hooks/useUser.ts +78 -0
- package/src/hooks/useWishlist.ts +119 -0
- package/src/index.ts +17 -0
- package/src/invoke.ts +181 -0
- package/src/loaders/ProductDetailsPage.ts +1 -0
- package/src/loaders/ProductList.ts +1 -0
- package/src/loaders/ProductListingPage.ts +1 -0
- package/src/loaders/address.ts +115 -0
- package/src/loaders/autocomplete.ts +58 -0
- package/src/loaders/brands.ts +44 -0
- package/src/loaders/cart.ts +52 -0
- package/src/loaders/catalog.ts +163 -0
- package/src/loaders/collections.ts +55 -0
- package/src/loaders/index.ts +19 -0
- package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
- package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
- package/src/loaders/intelligentSearch/productList.ts +154 -0
- package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
- package/src/loaders/intelligentSearch/suggestions.ts +46 -0
- package/src/loaders/legacy/productDetailsPage.ts +1 -0
- package/src/loaders/legacy/productList.ts +1 -0
- package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
- package/src/loaders/legacy.ts +635 -0
- package/src/loaders/logistics.ts +111 -0
- package/src/loaders/minicart.ts +78 -0
- package/src/loaders/navbar.ts +26 -0
- package/src/loaders/orders.ts +92 -0
- package/src/loaders/pageType.ts +68 -0
- package/src/loaders/payment.ts +97 -0
- package/src/loaders/productListFull.ts +159 -0
- package/src/loaders/profile.ts +138 -0
- package/src/loaders/promotion.ts +30 -0
- package/src/loaders/search.ts +124 -0
- package/src/loaders/session.ts +83 -0
- package/src/loaders/user.ts +87 -0
- package/src/loaders/wishlist.ts +89 -0
- package/src/loaders/wishlistProducts.ts +69 -0
- package/src/loaders/workflow/products.ts +64 -0
- package/src/loaders/workflow.ts +316 -0
- package/src/logo.png +0 -0
- package/src/middleware.ts +240 -0
- package/src/mod.ts +152 -0
- package/src/registry.ts +9 -0
- package/src/types.ts +248 -0
- package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
- package/src/utils/__tests__/fetch.test.ts +80 -0
- package/src/utils/__tests__/fetchCache.test.ts +112 -0
- package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
- package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
- package/src/utils/__tests__/minicart.test.ts +184 -0
- package/src/utils/__tests__/operationRouter.test.ts +227 -0
- package/src/utils/__tests__/resourceRange.test.ts +32 -0
- package/src/utils/__tests__/sitemap.test.ts +185 -0
- package/src/utils/__tests__/slugify.test.ts +31 -0
- package/src/utils/__tests__/transform.test.ts +698 -0
- package/src/utils/accountLoaders.ts +203 -0
- package/src/utils/authHelpers.ts +85 -0
- package/src/utils/batch.ts +18 -0
- package/src/utils/cookieSanitizer.ts +173 -0
- package/src/utils/cookies.ts +167 -0
- package/src/utils/enrichment.ts +560 -0
- package/src/utils/fetch.ts +107 -0
- package/src/utils/fetchCache.ts +222 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/instrumentedFetch.ts +78 -0
- package/src/utils/intelligentSearch.ts +96 -0
- package/src/utils/legacy.ts +139 -0
- package/src/utils/minicart.ts +139 -0
- package/src/utils/operationRouter.ts +139 -0
- package/src/utils/pickAndOmit.ts +25 -0
- package/src/utils/proxy.ts +435 -0
- package/src/utils/resourceRange.ts +10 -0
- package/src/utils/segment.ts +152 -0
- package/src/utils/similars.ts +37 -0
- package/src/utils/sitemap.ts +282 -0
- package/src/utils/slugCache.ts +28 -0
- package/src/utils/slugify.ts +13 -0
- package/src/utils/transform.ts +1509 -0
- package/src/utils/types.ts +1884 -0
- package/src/utils/vtexId.ts +138 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Workflow loaders (internal/back-office use).
|
|
3
|
+
* These transform raw VTEX Catalog data into schema.org-compatible Product types.
|
|
4
|
+
* NOT intended for storefront rendering — used in data pipelines and workflows.
|
|
5
|
+
*
|
|
6
|
+
* Pure async functions — require configureVtex() to have been called.
|
|
7
|
+
*
|
|
8
|
+
* Ported from deco-cx/apps:
|
|
9
|
+
* vtex/loaders/workflow/product.ts
|
|
10
|
+
* vtex/loaders/workflow/products.ts
|
|
11
|
+
*
|
|
12
|
+
* @see https://developers.vtex.com/docs/api-reference/catalog-api
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
Offer,
|
|
16
|
+
Product,
|
|
17
|
+
PropertyValue,
|
|
18
|
+
UnitPriceSpecification,
|
|
19
|
+
} from "@decocms/apps-commerce/types";
|
|
20
|
+
import { vtexFetch } from "../client";
|
|
21
|
+
import {
|
|
22
|
+
aggregateOffers,
|
|
23
|
+
toAdditionalPropertyCategory,
|
|
24
|
+
toAdditionalPropertyCluster,
|
|
25
|
+
toAdditionalPropertyReferenceId,
|
|
26
|
+
toAdditionalPropertySpecification,
|
|
27
|
+
} from "../utils/transform";
|
|
28
|
+
|
|
29
|
+
/** VTEX prices come in cents — divide by this to get the currency value. */
|
|
30
|
+
const CENTS_DIVISOR = 100;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Types for pvt Catalog APIs
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
interface SkuImage {
|
|
37
|
+
ImageUrl: string;
|
|
38
|
+
ImageName?: string;
|
|
39
|
+
FileId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SkuSpecification {
|
|
43
|
+
FieldName: string;
|
|
44
|
+
FieldValues: string[];
|
|
45
|
+
FieldValueIds: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SkuSeller {
|
|
49
|
+
SellerId: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SkuAlternateIds {
|
|
53
|
+
RefId?: string;
|
|
54
|
+
Ean?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PvtSku {
|
|
58
|
+
Id: number;
|
|
59
|
+
ProductId: number;
|
|
60
|
+
IsActive: boolean;
|
|
61
|
+
SkuName: string;
|
|
62
|
+
ProductName: string;
|
|
63
|
+
ProductDescription: string;
|
|
64
|
+
DetailUrl: string;
|
|
65
|
+
BrandId: string;
|
|
66
|
+
BrandName: string;
|
|
67
|
+
ReleaseDate?: string;
|
|
68
|
+
Images: SkuImage[];
|
|
69
|
+
SkuSpecifications: SkuSpecification[];
|
|
70
|
+
ProductSpecifications: SkuSpecification[];
|
|
71
|
+
ProductCategories: Record<string, string>;
|
|
72
|
+
ProductClusterNames: Record<string, string>;
|
|
73
|
+
SalesChannels: number[];
|
|
74
|
+
AlternateIds: SkuAlternateIds;
|
|
75
|
+
SkuSellers: SkuSeller[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PvtSkuListItem {
|
|
79
|
+
Id: number;
|
|
80
|
+
IsActive: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SalesChannel {
|
|
84
|
+
Id: number;
|
|
85
|
+
CurrencyCode: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface SimulationItem {
|
|
89
|
+
sellingPrice: number;
|
|
90
|
+
listPrice: number;
|
|
91
|
+
price: number;
|
|
92
|
+
seller: string;
|
|
93
|
+
priceValidUntil: string;
|
|
94
|
+
availability: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface SimulationPaymentOption {
|
|
98
|
+
paymentName: string;
|
|
99
|
+
installments: Array<{ count: number; value: number; total: number }>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface SimulationResponse {
|
|
103
|
+
items?: SimulationItem[];
|
|
104
|
+
paymentData?: { installmentOptions?: SimulationPaymentOption[] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// workflowProduct
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export interface WorkflowProductOptions {
|
|
112
|
+
/** The SKU ID (stockKeepingUnitId) to load */
|
|
113
|
+
productID: string;
|
|
114
|
+
/** Sales channel for simulation. Defaults to 1. */
|
|
115
|
+
salesChannel?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Transform a single VTEX SKU (via private Catalog API) into a commerce Product.
|
|
120
|
+
*
|
|
121
|
+
* Fetches the SKU details, all sibling SKUs for the same product, sales channels,
|
|
122
|
+
* and runs checkout simulation for each seller to build offer data.
|
|
123
|
+
*
|
|
124
|
+
* Ported from: vtex/loaders/workflow/product.ts
|
|
125
|
+
*/
|
|
126
|
+
export async function workflowProduct(opts: WorkflowProductOptions): Promise<Product | null> {
|
|
127
|
+
const sc = opts.salesChannel ?? 1;
|
|
128
|
+
|
|
129
|
+
const sku = await vtexFetch<PvtSku>(
|
|
130
|
+
`/api/catalog_system/pvt/sku/stockkeepingunitbyid/${opts.productID}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (!sku.IsActive) return null;
|
|
134
|
+
|
|
135
|
+
const [skus, salesChannels, ...simulations] = await Promise.all([
|
|
136
|
+
vtexFetch<PvtSkuListItem[]>(
|
|
137
|
+
`/api/catalog_system/pvt/sku/stockkeepingunitByProductId/${sku.ProductId}`,
|
|
138
|
+
),
|
|
139
|
+
vtexFetch<SalesChannel[]>("/api/catalog_system/pvt/saleschannel/list"),
|
|
140
|
+
...sku.SkuSellers.map(({ SellerId }) =>
|
|
141
|
+
vtexFetch<SimulationResponse>(
|
|
142
|
+
`/api/checkout/pub/orderForms/simulation?RnbBehavior=1&sc=${sc}`,
|
|
143
|
+
{
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
items: [{ id: `${sku.Id}`, seller: SellerId, quantity: 1 }],
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const channel = salesChannels.find((c) => c.Id === sc);
|
|
154
|
+
const productGroupID = `${sku.ProductId}`;
|
|
155
|
+
const productID = `${sku.Id}`;
|
|
156
|
+
|
|
157
|
+
const additionalProperty = [
|
|
158
|
+
sku.AlternateIds.RefId
|
|
159
|
+
? toAdditionalPropertyReferenceId({
|
|
160
|
+
name: "RefId",
|
|
161
|
+
value: sku.AlternateIds.RefId,
|
|
162
|
+
})
|
|
163
|
+
: null,
|
|
164
|
+
...Object.entries(sku.ProductCategories ?? {}).map(([propertyID, value]) =>
|
|
165
|
+
toAdditionalPropertyCategory({ propertyID, value }),
|
|
166
|
+
),
|
|
167
|
+
...Object.entries(sku.ProductClusterNames ?? {}).map(([propertyID, value]) =>
|
|
168
|
+
toAdditionalPropertyCluster({ propertyID, value }),
|
|
169
|
+
),
|
|
170
|
+
...sku.SkuSpecifications.flatMap((spec) =>
|
|
171
|
+
spec.FieldValues.map((value, it) =>
|
|
172
|
+
toAdditionalPropertySpecification({
|
|
173
|
+
propertyID: spec.FieldValueIds[it]?.toString(),
|
|
174
|
+
name: spec.FieldName,
|
|
175
|
+
value,
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
...sku.SalesChannels.map(
|
|
180
|
+
(ch): PropertyValue => ({
|
|
181
|
+
"@type": "PropertyValue",
|
|
182
|
+
name: "salesChannel",
|
|
183
|
+
propertyID: ch.toString(),
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
].filter((p): p is PropertyValue => Boolean(p));
|
|
187
|
+
|
|
188
|
+
const groupAdditionalProperty = sku.ProductSpecifications.flatMap((spec) =>
|
|
189
|
+
spec.FieldValues.map((value, it) =>
|
|
190
|
+
toAdditionalPropertySpecification({
|
|
191
|
+
propertyID: spec.FieldValueIds[it]?.toString(),
|
|
192
|
+
name: spec.FieldName,
|
|
193
|
+
value,
|
|
194
|
+
}),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const offers = simulations
|
|
199
|
+
.flatMap(({ items, paymentData }) =>
|
|
200
|
+
items?.map((item): Offer | null => {
|
|
201
|
+
const { sellingPrice, listPrice, price, seller, priceValidUntil, availability } = item;
|
|
202
|
+
const spotPrice = sellingPrice || price;
|
|
203
|
+
if (!spotPrice || !listPrice) return null;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
"@type": "Offer",
|
|
207
|
+
price: spotPrice / CENTS_DIVISOR,
|
|
208
|
+
seller,
|
|
209
|
+
priceValidUntil,
|
|
210
|
+
inventoryLevel: {},
|
|
211
|
+
availability:
|
|
212
|
+
availability === "available"
|
|
213
|
+
? "https://schema.org/InStock"
|
|
214
|
+
: "https://schema.org/OutOfStock",
|
|
215
|
+
priceSpecification: [
|
|
216
|
+
{
|
|
217
|
+
"@type": "UnitPriceSpecification",
|
|
218
|
+
priceType: "https://schema.org/ListPrice",
|
|
219
|
+
price: listPrice / CENTS_DIVISOR,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
"@type": "UnitPriceSpecification",
|
|
223
|
+
priceType: "https://schema.org/SalePrice",
|
|
224
|
+
price: spotPrice / CENTS_DIVISOR,
|
|
225
|
+
},
|
|
226
|
+
...(paymentData?.installmentOptions?.flatMap((option): UnitPriceSpecification[] =>
|
|
227
|
+
option.installments.map((i) => ({
|
|
228
|
+
"@type": "UnitPriceSpecification",
|
|
229
|
+
priceType: "https://schema.org/SalePrice",
|
|
230
|
+
priceComponentType: "https://schema.org/Installment",
|
|
231
|
+
name: option.paymentName,
|
|
232
|
+
billingDuration: i.count,
|
|
233
|
+
billingIncrement: i.value / CENTS_DIVISOR,
|
|
234
|
+
price: i.total / CENTS_DIVISOR,
|
|
235
|
+
})),
|
|
236
|
+
) ?? []),
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
.filter((o): o is Offer => Boolean(o));
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
"@type": "Product",
|
|
245
|
+
productID,
|
|
246
|
+
sku: productID,
|
|
247
|
+
inProductGroupWithID: productGroupID,
|
|
248
|
+
category: Object.values(sku.ProductCategories ?? {}).join(" > "),
|
|
249
|
+
url: `${sku.DetailUrl}?skuId=${productID}`,
|
|
250
|
+
name: sku.SkuName,
|
|
251
|
+
gtin: sku.AlternateIds.Ean,
|
|
252
|
+
image: sku.Images.map((img) => ({
|
|
253
|
+
"@type": "ImageObject",
|
|
254
|
+
encodingFormat: "image",
|
|
255
|
+
alternateName: img.ImageName ?? img.FileId,
|
|
256
|
+
url: img.ImageUrl,
|
|
257
|
+
})),
|
|
258
|
+
isVariantOf: {
|
|
259
|
+
"@type": "ProductGroup",
|
|
260
|
+
url: sku.DetailUrl,
|
|
261
|
+
hasVariant:
|
|
262
|
+
skus
|
|
263
|
+
?.filter((x) => x.IsActive)
|
|
264
|
+
.map(({ Id }) => ({
|
|
265
|
+
"@type": "Product",
|
|
266
|
+
productID: `${Id}`,
|
|
267
|
+
sku: `${Id}`,
|
|
268
|
+
})) ?? [],
|
|
269
|
+
additionalProperty: groupAdditionalProperty,
|
|
270
|
+
productGroupID,
|
|
271
|
+
name: sku.ProductName,
|
|
272
|
+
description: sku.ProductDescription,
|
|
273
|
+
},
|
|
274
|
+
additionalProperty,
|
|
275
|
+
releaseDate: sku.ReleaseDate ? new Date(sku.ReleaseDate).toISOString() : undefined,
|
|
276
|
+
brand: {
|
|
277
|
+
"@type": "Brand",
|
|
278
|
+
"@id": sku.BrandId,
|
|
279
|
+
name: sku.BrandName,
|
|
280
|
+
},
|
|
281
|
+
offers: aggregateOffers(offers, channel?.CurrencyCode),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// workflowProducts
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
export interface WorkflowProductsOptions {
|
|
290
|
+
page: number;
|
|
291
|
+
pagesize: number;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Fetch a page of SKU IDs and return minimal Product stubs.
|
|
296
|
+
* Use in batch workflows to enumerate the catalog; call workflowProduct
|
|
297
|
+
* for each ID if full details are needed.
|
|
298
|
+
*
|
|
299
|
+
* Ported from: vtex/loaders/workflow/products.ts
|
|
300
|
+
*/
|
|
301
|
+
export async function workflowProducts(opts: WorkflowProductsOptions): Promise<Product[]> {
|
|
302
|
+
const params = new URLSearchParams({
|
|
303
|
+
page: String(opts.page),
|
|
304
|
+
pagesize: String(opts.pagesize),
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const ids = await vtexFetch<number[]>(
|
|
308
|
+
`/api/catalog_system/pvt/sku/stockkeepingunitids?${params}`,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
return ids.map((productID) => ({
|
|
312
|
+
"@type": "Product",
|
|
313
|
+
productID: `${productID}`,
|
|
314
|
+
sku: `${productID}`,
|
|
315
|
+
}));
|
|
316
|
+
}
|
package/src/logo.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX Middleware utilities for TanStack Start.
|
|
3
|
+
*
|
|
4
|
+
* Extracts segment information from cookies/URL params, detects login state,
|
|
5
|
+
* propagates Intelligent Search cookies, and provides cache-control decisions.
|
|
6
|
+
*
|
|
7
|
+
* Use with TanStack Start's createMiddleware() in the storefront:
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createMiddleware } from "@tanstack/react-start";
|
|
12
|
+
* import {
|
|
13
|
+
* extractVtexContext,
|
|
14
|
+
* vtexCacheControl,
|
|
15
|
+
* } from "@decocms/apps/vtex/middleware";
|
|
16
|
+
*
|
|
17
|
+
* const vtexMiddleware = createMiddleware().server(async ({ next, request }) => {
|
|
18
|
+
* const vtexCtx = extractVtexContext(request);
|
|
19
|
+
* const response = await next();
|
|
20
|
+
* response.headers.set("Cache-Control", vtexCacheControl(vtexCtx));
|
|
21
|
+
* propagateISCookies(request, response);
|
|
22
|
+
* return response;
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
|
|
28
|
+
import {
|
|
29
|
+
buildSegmentFromParams,
|
|
30
|
+
DEFAULT_SEGMENT,
|
|
31
|
+
parseSegment,
|
|
32
|
+
SALES_CHANNEL_COOKIE,
|
|
33
|
+
SEGMENT_COOKIE_NAME,
|
|
34
|
+
serializeSegment,
|
|
35
|
+
} from "./utils/segment";
|
|
36
|
+
import type { Segment } from "./utils/types";
|
|
37
|
+
import { extractVtexAuthCookie, parseVtexAuthToken } from "./utils/vtexId";
|
|
38
|
+
|
|
39
|
+
// -------------------------------------------------------------------------
|
|
40
|
+
// Types
|
|
41
|
+
// -------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
export interface VtexRequestContext {
|
|
44
|
+
/** Decoded segment from cookie or URL params. */
|
|
45
|
+
segment: Partial<Segment>;
|
|
46
|
+
/** Serialized segment token for cache key use. */
|
|
47
|
+
segmentToken: string;
|
|
48
|
+
/** Whether the user has a valid (non-expired) VTEX auth cookie. */
|
|
49
|
+
isLoggedIn: boolean;
|
|
50
|
+
/** Extracted email from the auth JWT, if available. */
|
|
51
|
+
email?: string;
|
|
52
|
+
/** Sales channel derived from segment. */
|
|
53
|
+
salesChannel: string;
|
|
54
|
+
/**
|
|
55
|
+
* VTEX region ID from the segment cookie.
|
|
56
|
+
* Present when the user has set a postal code (CEP) for regionalization.
|
|
57
|
+
* Null when no region is set (anonymous default segment).
|
|
58
|
+
*/
|
|
59
|
+
regionId: string | null;
|
|
60
|
+
/** Whether this request carries price tables (B2B). */
|
|
61
|
+
hasCustomPricing: boolean;
|
|
62
|
+
/** Intelligent Search session cookie. */
|
|
63
|
+
isSessionId: string;
|
|
64
|
+
/** Intelligent Search anonymous cookie. */
|
|
65
|
+
isAnonymousId: string;
|
|
66
|
+
/** Whether IS cookies were freshly generated (browser didn't send them). */
|
|
67
|
+
needsISCookies: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
// Cookie helpers
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const _IS_COOKIE_PREFIX = "vtex_is_";
|
|
75
|
+
|
|
76
|
+
/** Seconds in one day (86 400). Used for cookie Max-Age and stale-if-error. */
|
|
77
|
+
const ONE_DAY_SECONDS = 86_400;
|
|
78
|
+
|
|
79
|
+
/** Seconds in one year (~365 days). Used for long-lived IS cookie Max-Age. */
|
|
80
|
+
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
|
|
81
|
+
|
|
82
|
+
function getCookieValue(cookieHeader: string, name: string): string | null {
|
|
83
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
84
|
+
return match?.[1] ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
// Core extraction
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract VTEX context from an incoming request.
|
|
93
|
+
*
|
|
94
|
+
* Reads the segment cookie, URL params (utm_*, sc), and auth cookie
|
|
95
|
+
* to build a complete picture of the user's VTEX session state.
|
|
96
|
+
*/
|
|
97
|
+
function generateUUID(): string {
|
|
98
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
99
|
+
return crypto.randomUUID();
|
|
100
|
+
}
|
|
101
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
102
|
+
const r = (Math.random() * 16) | 0;
|
|
103
|
+
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function extractVtexContext(request: Request): VtexRequestContext {
|
|
108
|
+
const cookies = request.headers.get("cookie") ?? "";
|
|
109
|
+
const url = new URL(request.url);
|
|
110
|
+
|
|
111
|
+
const segmentCookie = getCookieValue(cookies, SEGMENT_COOKIE_NAME);
|
|
112
|
+
const cookieSegment = segmentCookie ? parseSegment(segmentCookie) : null;
|
|
113
|
+
|
|
114
|
+
const paramSegment = buildSegmentFromParams(url.searchParams);
|
|
115
|
+
|
|
116
|
+
const vtexsc = getCookieValue(cookies, SALES_CHANNEL_COOKIE);
|
|
117
|
+
|
|
118
|
+
const segment: Partial<Segment> = {
|
|
119
|
+
...DEFAULT_SEGMENT,
|
|
120
|
+
...cookieSegment,
|
|
121
|
+
...paramSegment,
|
|
122
|
+
};
|
|
123
|
+
if (vtexsc) segment.channel = vtexsc;
|
|
124
|
+
|
|
125
|
+
const segmentToken = serializeSegment(segment);
|
|
126
|
+
|
|
127
|
+
const authToken = extractVtexAuthCookie(cookies);
|
|
128
|
+
const authInfo = authToken ? parseVtexAuthToken(authToken) : null;
|
|
129
|
+
|
|
130
|
+
const existingSessionId = getCookieValue(cookies, SESSION_COOKIE);
|
|
131
|
+
const existingAnonymousId = getCookieValue(cookies, ANONYMOUS_COOKIE);
|
|
132
|
+
const needsISCookies = !existingSessionId || !existingAnonymousId;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
segment,
|
|
136
|
+
segmentToken,
|
|
137
|
+
isLoggedIn: authInfo?.isLoggedIn ?? false,
|
|
138
|
+
email: authInfo?.email,
|
|
139
|
+
salesChannel: segment.channel ?? "1",
|
|
140
|
+
regionId: segment.regionId ?? null,
|
|
141
|
+
hasCustomPricing: Boolean(segment.priceTables && segment.priceTables.length > 0),
|
|
142
|
+
isSessionId: existingSessionId ?? generateUUID(),
|
|
143
|
+
isAnonymousId: existingAnonymousId ?? generateUUID(),
|
|
144
|
+
needsISCookies,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -------------------------------------------------------------------------
|
|
149
|
+
// Cache control
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Determine the appropriate Cache-Control header based on VTEX context.
|
|
154
|
+
*
|
|
155
|
+
* Rules:
|
|
156
|
+
* - Logged-in users: private (personalized prices, wishlists, etc.)
|
|
157
|
+
* - Custom pricing (B2B): private (price table specific)
|
|
158
|
+
* - Anonymous default segment: public with CDN caching
|
|
159
|
+
*/
|
|
160
|
+
export function vtexCacheControl(
|
|
161
|
+
ctx: VtexRequestContext,
|
|
162
|
+
options?: {
|
|
163
|
+
/** Max age for public (anonymous) responses in seconds. @default 60 */
|
|
164
|
+
publicMaxAge?: number;
|
|
165
|
+
/** Stale-while-revalidate for public responses in seconds. @default 3600 */
|
|
166
|
+
publicSWR?: number;
|
|
167
|
+
},
|
|
168
|
+
): string {
|
|
169
|
+
if (ctx.isLoggedIn || ctx.hasCustomPricing) {
|
|
170
|
+
return "private, no-cache, no-store, must-revalidate";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const maxAge = options?.publicMaxAge ?? 60;
|
|
174
|
+
const swr = options?.publicSWR ?? 3600;
|
|
175
|
+
|
|
176
|
+
return `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}, stale-if-error=${ONE_DAY_SECONDS}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
// Cookie propagation
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
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.
|
|
188
|
+
*/
|
|
189
|
+
export function propagateISCookies(ctx: VtexRequestContext, response: Response): void {
|
|
190
|
+
if (!ctx.needsISCookies) return;
|
|
191
|
+
|
|
192
|
+
const maxAge = ONE_YEAR_SECONDS;
|
|
193
|
+
response.headers.append(
|
|
194
|
+
"Set-Cookie",
|
|
195
|
+
`${SESSION_COOKIE}=${ctx.isSessionId}; Path=/; SameSite=Lax; Max-Age=${maxAge}`,
|
|
196
|
+
);
|
|
197
|
+
response.headers.append(
|
|
198
|
+
"Set-Cookie",
|
|
199
|
+
`${ANONYMOUS_COOKIE}=${ctx.isAnonymousId}; Path=/; SameSite=Lax; Max-Age=${maxAge}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build a segment cookie Set-Cookie header for the response.
|
|
205
|
+
*
|
|
206
|
+
* Use this when URL params change the segment (e.g., ?sc=2) so the
|
|
207
|
+
* browser persists the new segment for subsequent requests.
|
|
208
|
+
*/
|
|
209
|
+
export function buildSegmentSetCookie(segment: Partial<Segment>, domain?: string): string {
|
|
210
|
+
const token = serializeSegment(segment);
|
|
211
|
+
let cookie = `${SEGMENT_COOKIE_NAME}=${token}; Path=/; SameSite=Lax; Max-Age=${ONE_DAY_SECONDS}`;
|
|
212
|
+
if (domain) cookie += `; Domain=${domain}`;
|
|
213
|
+
return cookie;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
// Cache key helpers
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a cache key suffix from the VTEX context.
|
|
222
|
+
*
|
|
223
|
+
* This is used in the Cloudflare Worker entry to differentiate cached
|
|
224
|
+
* responses by segment. Two anonymous users on the same sales channel
|
|
225
|
+
* get the same cache key; a logged-in user gets a unique (uncached) key.
|
|
226
|
+
*/
|
|
227
|
+
export function vtexCacheKeySuffix(ctx: VtexRequestContext): string {
|
|
228
|
+
if (ctx.isLoggedIn) return "__vtex_auth";
|
|
229
|
+
const parts = [`sc=${ctx.salesChannel}`];
|
|
230
|
+
if (ctx.regionId) parts.push(`r=${ctx.regionId}`);
|
|
231
|
+
return `__vtex_${parts.join("_")}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// Re-exports for convenience
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export type { Segment } from "./utils/types";
|
|
239
|
+
export type { VtexAuthInfo } from "./utils/vtexId";
|
|
240
|
+
export { isVtexLoggedIn } from "./utils/vtexId";
|
package/src/mod.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX app module — standard autoconfig contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports `configure` following the AppModContract pattern.
|
|
5
|
+
* The framework's `autoconfigApps()` calls these generically.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import * as vtexApp from "@decocms/apps/vtex/mod";
|
|
10
|
+
*
|
|
11
|
+
* const app = await vtexApp.configure(blocks.vtex, resolveSecret);
|
|
12
|
+
* if (app) {
|
|
13
|
+
* // app.manifest, app.state, app.middleware are available
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AppDefinition, AppMiddleware, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
|
|
19
|
+
import type { Secret } from "@decocms/apps-website/mod";
|
|
20
|
+
import { configureVtex, type VtexConfig } from "./client";
|
|
21
|
+
import manifest from "./manifest.gen";
|
|
22
|
+
import { extractVtexContext, propagateISCookies, vtexCacheControl } from "./middleware";
|
|
23
|
+
|
|
24
|
+
// -------------------------------------------------------------------------
|
|
25
|
+
// CMS Props (mirrors deco-cx/apps/vtex/mod.ts)
|
|
26
|
+
// -------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** @title VTEX */
|
|
29
|
+
export interface Props {
|
|
30
|
+
/**
|
|
31
|
+
* @description VTEX Account name
|
|
32
|
+
*/
|
|
33
|
+
account: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @title Public store URL
|
|
37
|
+
* @description Domain registered on License Manager (e.g. secure.mystore.com.br)
|
|
38
|
+
*/
|
|
39
|
+
publicUrl: string;
|
|
40
|
+
|
|
41
|
+
/** @title App Key */
|
|
42
|
+
appKey?: Secret;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @title App Token
|
|
46
|
+
* @format password
|
|
47
|
+
*/
|
|
48
|
+
appToken?: Secret;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @title Default Sales Channel
|
|
52
|
+
* @deprecated
|
|
53
|
+
*/
|
|
54
|
+
salesChannel?: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @title Set Refresh Token
|
|
58
|
+
* @default false
|
|
59
|
+
*/
|
|
60
|
+
setRefreshToken?: boolean;
|
|
61
|
+
|
|
62
|
+
defaultSegment?: Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
usePortalSitemap?: boolean;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @hide true
|
|
68
|
+
* @default vtex
|
|
69
|
+
*/
|
|
70
|
+
platform?: "vtex";
|
|
71
|
+
|
|
72
|
+
advancedConfigs?: {
|
|
73
|
+
doNotFetchVariantsForRelatedProducts?: boolean;
|
|
74
|
+
removeUTMFromCacheKey?: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** @title Cached Search Terms */
|
|
78
|
+
cachedSearchTerms?: {
|
|
79
|
+
terms?: unknown;
|
|
80
|
+
extraTerms?: string[];
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type { Secret };
|
|
85
|
+
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// State
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export interface VtexState {
|
|
91
|
+
config: VtexConfig;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -------------------------------------------------------------------------
|
|
95
|
+
// Middleware
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
const vtexMiddleware: AppMiddleware = async (request, next) => {
|
|
99
|
+
const ctx = extractVtexContext(request);
|
|
100
|
+
const response = await next();
|
|
101
|
+
response.headers.set("Cache-Control", vtexCacheControl(ctx));
|
|
102
|
+
propagateISCookies(ctx, response);
|
|
103
|
+
return response;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// -------------------------------------------------------------------------
|
|
107
|
+
// Configure
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Configure the VTEX app from CMS block data.
|
|
112
|
+
* Returns an AppDefinition or null if required fields are missing.
|
|
113
|
+
*/
|
|
114
|
+
export async function configure(
|
|
115
|
+
// biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema
|
|
116
|
+
block: any,
|
|
117
|
+
resolveSecret: ResolveSecretFn,
|
|
118
|
+
): Promise<AppDefinition<VtexState> | null> {
|
|
119
|
+
if (!block?.account) return null;
|
|
120
|
+
|
|
121
|
+
const appKey = await resolveSecret(block.appKey, "VTEX_APP_KEY");
|
|
122
|
+
const appToken = await resolveSecret(block.appToken, "VTEX_APP_TOKEN");
|
|
123
|
+
|
|
124
|
+
const config: VtexConfig = {
|
|
125
|
+
account: block.account,
|
|
126
|
+
publicUrl: block.publicUrl,
|
|
127
|
+
salesChannel: block.salesChannel || "1",
|
|
128
|
+
locale: block.locale || block.defaultLocale,
|
|
129
|
+
appKey: appKey ?? undefined,
|
|
130
|
+
appToken: appToken ?? undefined,
|
|
131
|
+
country: block.country,
|
|
132
|
+
domain: block.domain,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Bridge: maintain global singleton for backward compat
|
|
136
|
+
configureVtex(config);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
name: "vtex",
|
|
140
|
+
manifest,
|
|
141
|
+
state: { config },
|
|
142
|
+
middleware: vtexMiddleware,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Placeholder preview for CMS editor — evolves when admin supports it. */
|
|
147
|
+
export const preview = undefined;
|
|
148
|
+
|
|
149
|
+
/** Default export for schema generation and Deno-style app bridges. */
|
|
150
|
+
export default function VTEX(_props: Props) {
|
|
151
|
+
return { state: _props };
|
|
152
|
+
}
|