@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
package/src/client.ts
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VTEX API Client for TanStack Start.
|
|
3
|
+
* Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
InstrumentedFetch,
|
|
8
|
+
InstrumentedFetchInit,
|
|
9
|
+
} from "@decocms/blocks/sdk/instrumentedFetch";
|
|
10
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
11
|
+
import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
|
|
12
|
+
import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
|
|
13
|
+
import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
|
|
14
|
+
import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Outgoing response headers for the active request, or `null` when
|
|
18
|
+
* called outside a request scope (which happens during module init).
|
|
19
|
+
* `RequestContext.responseHeaders` was added to `@decocms/start` in
|
|
20
|
+
* v0.39.0; we now require >=2.5.0 as a devDep so the property is
|
|
21
|
+
* always typed/present.
|
|
22
|
+
*/
|
|
23
|
+
function getResponseHeaders(): Headers | null {
|
|
24
|
+
const ctx = RequestContext.current;
|
|
25
|
+
return ctx ? ctx.responseHeaders : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hostname of the active storefront request, or `null` outside a request
|
|
30
|
+
* scope. Used to rewrite the `Domain` attribute of VTEX `Set-Cookie`
|
|
31
|
+
* headers so server-function cookies are scoped identically to the ones
|
|
32
|
+
* `createVtexCheckoutProxy` emits (`rewriteSetCookieDomain` → `url.hostname`)
|
|
33
|
+
* and to what VTEX itself sets natively (`domain=<host>`).
|
|
34
|
+
*/
|
|
35
|
+
function getRequestHost(): string | null {
|
|
36
|
+
const ctx = RequestContext.current;
|
|
37
|
+
if (!ctx) return null;
|
|
38
|
+
try {
|
|
39
|
+
return new URL(ctx.request.url).hostname;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a VTEX `Set-Cookie` so the browser accepts it on the storefront
|
|
47
|
+
* host AND so it lands at the SAME cookie scope as the checkout proxy.
|
|
48
|
+
*
|
|
49
|
+
* VTEX sets `checkout.vtex.com` / `CheckoutOrderFormOwnership` with
|
|
50
|
+
* `domain=<vtex-host>` (e.g. `casaevideonewio.vtexcommercestable.com.br`),
|
|
51
|
+
* which the browser would reject on the storefront host. There are two ways
|
|
52
|
+
* to make it acceptable:
|
|
53
|
+
*
|
|
54
|
+
* - strip the `Domain` attribute → host-only cookie
|
|
55
|
+
* - rewrite `Domain` to `<host>` → domain-scoped cookie
|
|
56
|
+
*
|
|
57
|
+
* The checkout proxy does the latter. If this path does the former, the
|
|
58
|
+
* cart's cookie (host-only) and the proxy's cookie (domain-scoped) become
|
|
59
|
+
* TWO DISTINCT cookies in the browser: they don't overwrite each other, can
|
|
60
|
+
* drift to different orderForm ids, and VTEX reads whichever is sent last
|
|
61
|
+
* (RFC 6265 §5.4 orders by creation time) — a nondeterministic empty-cart
|
|
62
|
+
* bug. Rewriting (instead of stripping) keeps both writers on the same key
|
|
63
|
+
* so the newest write always wins. When the host is unknown (only at module
|
|
64
|
+
* init, never inside a real request) we fall back to stripping.
|
|
65
|
+
*/
|
|
66
|
+
function rewriteCookieDomain(cookie: string, host: string | null): string {
|
|
67
|
+
// Anchor to an attribute boundary (`; `) so we never touch a `domain=`
|
|
68
|
+
// substring that happens to live inside the cookie value (before the
|
|
69
|
+
// first `;`).
|
|
70
|
+
return host
|
|
71
|
+
? cookie.replace(/(;\s*)domain=[^;]*/i, `$1Domain=${host}`)
|
|
72
|
+
: cookie.replace(/;\s*domain=[^;]*/gi, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// URL sanitization (ported from deco-cx/apps vtex/utils/fetchVTEX.ts)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const removeNonLatin1Chars = (str: string): string => str.replace(/[^\x00-\x7F]|["']/g, "");
|
|
80
|
+
|
|
81
|
+
const removeScriptChars = (str: string): string => {
|
|
82
|
+
return str
|
|
83
|
+
.replace(/\+/g, "")
|
|
84
|
+
.replaceAll(" ", "")
|
|
85
|
+
.replace(/[[\]{}()<>]/g, "")
|
|
86
|
+
.replace(/[/\\]/g, "")
|
|
87
|
+
.replace(/\./g, "")
|
|
88
|
+
.normalize("NFD")
|
|
89
|
+
.replace(/[\u0300-\u036f]/g, "");
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function sanitizeUrl(input: string): string {
|
|
93
|
+
let url: URL;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(input);
|
|
96
|
+
} catch {
|
|
97
|
+
return input;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const QS_TO_SANITIZE = ["utm_campaign", "utm_medium", "utm_source", "map"];
|
|
101
|
+
for (const qs of QS_TO_SANITIZE) {
|
|
102
|
+
if (url.searchParams.has(qs)) {
|
|
103
|
+
const values = url.searchParams.getAll(qs);
|
|
104
|
+
url.searchParams.delete(qs);
|
|
105
|
+
for (const v of values) {
|
|
106
|
+
const sanitized = removeScriptChars(removeNonLatin1Chars(v));
|
|
107
|
+
if (sanitized) url.searchParams.append(qs, sanitized);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const QS_TO_ENCODE = ["ft"];
|
|
113
|
+
for (const qs of QS_TO_ENCODE) {
|
|
114
|
+
if (url.searchParams.has(qs)) {
|
|
115
|
+
const values = url.searchParams.getAll(qs);
|
|
116
|
+
url.searchParams.delete(qs);
|
|
117
|
+
for (const v of values) {
|
|
118
|
+
url.searchParams.append(qs, encodeURIComponent(v.trim()));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return url.toString();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Config
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface VtexConfig {
|
|
131
|
+
account: string;
|
|
132
|
+
publicUrl?: string;
|
|
133
|
+
salesChannel?: string;
|
|
134
|
+
locale?: string;
|
|
135
|
+
appKey?: string;
|
|
136
|
+
appToken?: string;
|
|
137
|
+
/**
|
|
138
|
+
* ISO 3166-1 alpha-3 country code used for simulation/checkout.
|
|
139
|
+
* @default "BRA"
|
|
140
|
+
*/
|
|
141
|
+
country?: string;
|
|
142
|
+
/**
|
|
143
|
+
* VTEX domain suffix. Override for non-standard VTEX environments.
|
|
144
|
+
* @default "com.br"
|
|
145
|
+
*/
|
|
146
|
+
domain?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let _config: VtexConfig | null = null;
|
|
150
|
+
let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch;
|
|
151
|
+
|
|
152
|
+
export function configureVtex(config: VtexConfig) {
|
|
153
|
+
_config = config;
|
|
154
|
+
console.log(`[VTEX] Configured: ${config.account}.vtexcommercestable.com.br`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Override the fetch function used by all VTEX client calls.
|
|
159
|
+
* Pass an `InstrumentedFetch` to get spans, traceparent injection,
|
|
160
|
+
* URL redaction, and the canonical `http.client.request.duration` histogram —
|
|
161
|
+
* use the pre-wired `createVtexFetch()` factory:
|
|
162
|
+
*
|
|
163
|
+
* ```ts
|
|
164
|
+
* import { setVtexFetch, createVtexFetch } from "@decocms/apps/vtex";
|
|
165
|
+
* setVtexFetch(createVtexFetch());
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* Accepts a plain `typeof fetch` too; in that mode VTEX calls are
|
|
169
|
+
* uninstrumented (useful for tests + sites that haven't onboarded
|
|
170
|
+
* the observability stack yet).
|
|
171
|
+
*/
|
|
172
|
+
export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) {
|
|
173
|
+
_fetch = fetchFn;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read-only accessor for the configured VTEX fetch. Used by ad-hoc
|
|
178
|
+
* callsites that don't fit the `vtexFetch*` helpers (FormData
|
|
179
|
+
* uploads, the storefront proxies, .aspx endpoints) but still want
|
|
180
|
+
* to participate in the instrumentation set up via `setVtexFetch`.
|
|
181
|
+
*
|
|
182
|
+
* Callers can stamp a per-call operation through the init:
|
|
183
|
+
*
|
|
184
|
+
* ```ts
|
|
185
|
+
* const fetch = getVtexFetch();
|
|
186
|
+
* await fetch(url, { method: "POST", operation: "notifyme" });
|
|
187
|
+
* ```
|
|
188
|
+
*/
|
|
189
|
+
export function getVtexFetch(): InstrumentedFetch {
|
|
190
|
+
return _fetch as InstrumentedFetch;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function getVtexConfig(): VtexConfig {
|
|
194
|
+
if (!_config) throw new Error("VTEX not configured. Call configureVtex() first.");
|
|
195
|
+
return _config;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build the VTEX hostname for a given environment.
|
|
200
|
+
* Centralizes `{account}.{env}.{domain}` so nothing is hardcoded.
|
|
201
|
+
*/
|
|
202
|
+
export function vtexHost(environment: string = "vtexcommercestable", config?: VtexConfig): string {
|
|
203
|
+
const c = config ?? getVtexConfig();
|
|
204
|
+
const domain = c.domain ?? "com.br";
|
|
205
|
+
return `${c.account}.${environment}.${domain}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function baseUrl(): string {
|
|
209
|
+
return `https://${vtexHost()}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isUrl(): string {
|
|
213
|
+
return `https://${vtexHost()}/api/io/_v/api/intelligent-search`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function authHeaders(): Record<string, string> {
|
|
217
|
+
const c = getVtexConfig();
|
|
218
|
+
const headers: Record<string, string> = {
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
Accept: "application/json",
|
|
221
|
+
};
|
|
222
|
+
if (c.appKey && c.appToken) {
|
|
223
|
+
headers["X-VTEX-API-AppKey"] = c.appKey;
|
|
224
|
+
headers["X-VTEX-API-AppToken"] = c.appToken;
|
|
225
|
+
}
|
|
226
|
+
return headers;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read regionId from the current request's vtex_segment cookie.
|
|
231
|
+
* Returns null when outside a request context or no regionId is set.
|
|
232
|
+
*/
|
|
233
|
+
function extractRegionIdFromCookies(): string | null {
|
|
234
|
+
const ctx = RequestContext.current;
|
|
235
|
+
if (!ctx) return null;
|
|
236
|
+
const cookies = ctx.request.headers.get("cookie");
|
|
237
|
+
if (!cookies) return null;
|
|
238
|
+
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`));
|
|
239
|
+
if (!match?.[1]) return null;
|
|
240
|
+
const segment = parseSegment(match[1]);
|
|
241
|
+
return segment?.regionId ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read the raw `vtex_segment=<token>` cookie from the active request.
|
|
246
|
+
* Returns null when outside a request context or no segment cookie is set.
|
|
247
|
+
*
|
|
248
|
+
* Used to forward the segment cookie on outgoing VTEX API calls so
|
|
249
|
+
* Legacy Catalog endpoints (which gate on the cookie, not on
|
|
250
|
+
* `?regionId=` query params) see the right region for products
|
|
251
|
+
* available only through regional sellers.
|
|
252
|
+
*/
|
|
253
|
+
function getSegmentCookieHeader(): string | null {
|
|
254
|
+
const ctx = RequestContext.current;
|
|
255
|
+
if (!ctx) return null;
|
|
256
|
+
const cookies = ctx.request.headers.get("cookie");
|
|
257
|
+
if (!cookies) return null;
|
|
258
|
+
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`));
|
|
259
|
+
if (!match?.[1]) return null;
|
|
260
|
+
return `${SEGMENT_COOKIE_NAME}=${match[1]}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Case-insensitive lookup for `cookie` / `Cookie` in a headers init. */
|
|
264
|
+
function hasCookieHeader(headers: HeadersInit | undefined): boolean {
|
|
265
|
+
if (!headers) return false;
|
|
266
|
+
if (headers instanceof Headers) return headers.has("cookie");
|
|
267
|
+
if (Array.isArray(headers)) {
|
|
268
|
+
return headers.some(([k]) => k.toLowerCase() === "cookie");
|
|
269
|
+
}
|
|
270
|
+
return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Read the cookie header value from any HeadersInit shape.
|
|
275
|
+
* Returns undefined when no cookie header is set.
|
|
276
|
+
*/
|
|
277
|
+
function readCookieHeader(headers: HeadersInit | undefined): string | undefined {
|
|
278
|
+
if (!headers) return undefined;
|
|
279
|
+
if (headers instanceof Headers) return headers.get("cookie") ?? undefined;
|
|
280
|
+
if (Array.isArray(headers)) {
|
|
281
|
+
const found = headers.find(([k]) => k.toLowerCase() === "cookie");
|
|
282
|
+
return found?.[1];
|
|
283
|
+
}
|
|
284
|
+
const rec = headers as Record<string, string>;
|
|
285
|
+
const key = Object.keys(rec).find((k) => k.toLowerCase() === "cookie");
|
|
286
|
+
return key ? rec[key] : undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Return a new Headers instance that copies `headers` and replaces the
|
|
291
|
+
* `cookie` value with `cookieValue` (or removes it when undefined).
|
|
292
|
+
* Centralises the "merge cookie into existing init.headers" operation so
|
|
293
|
+
* we never spread a Headers instance as a plain object — that collapses
|
|
294
|
+
* to {} because Headers has no own enumerable entries, and silently
|
|
295
|
+
* wipes every other header the caller set. See PR #53.
|
|
296
|
+
*/
|
|
297
|
+
function withCookieHeader(
|
|
298
|
+
headers: HeadersInit | undefined,
|
|
299
|
+
cookieValue: string | undefined,
|
|
300
|
+
): Headers {
|
|
301
|
+
const next = new Headers(headers ?? {});
|
|
302
|
+
if (cookieValue) next.set("cookie", cookieValue);
|
|
303
|
+
else next.delete("cookie");
|
|
304
|
+
return next;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function vtexFetchResponse(
|
|
308
|
+
path: string,
|
|
309
|
+
init?: InstrumentedFetchInit,
|
|
310
|
+
): Promise<Response> {
|
|
311
|
+
const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
|
|
312
|
+
const url = sanitizeUrl(raw);
|
|
313
|
+
|
|
314
|
+
// Forward the incoming `vtex_segment` cookie on outgoing calls when
|
|
315
|
+
// the caller hasn't set a cookie header explicitly. This is what the
|
|
316
|
+
// Legacy Catalog API (and a handful of other VTEX endpoints) needs
|
|
317
|
+
// to resolve regional sellers correctly. Without it, products only
|
|
318
|
+
// available via a region's seller appear as OutOfStock on PDPs even
|
|
319
|
+
// for users with the cookie. Sites used to wrap `_fetch` themselves
|
|
320
|
+
// to do this — see https://github.com/decocms/apps-start#regional-sellers
|
|
321
|
+
const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
|
|
322
|
+
|
|
323
|
+
const response = await _fetch(url, {
|
|
324
|
+
...init,
|
|
325
|
+
headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
|
|
326
|
+
});
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
throw new Error(`VTEX API error: ${response.status} ${response.statusText} - ${url}`);
|
|
329
|
+
}
|
|
330
|
+
return response;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Combine framework headers + optional segment cookie + caller headers,
|
|
335
|
+
* preserving the precedence "caller wins" regardless of whether the
|
|
336
|
+
* caller passed `Headers`, `string[][]`, or `Record<string, string>`.
|
|
337
|
+
*
|
|
338
|
+
* Why a helper: the naive `{ ...authHeaders, ...init?.headers }` spread
|
|
339
|
+
* silently collapses a `Headers` instance to `{}` (Headers has no own
|
|
340
|
+
* enumerable entries), which means any cookies the caller put on a
|
|
341
|
+
* Headers object are lost on the wire. The `createVtexCheckoutProxy`
|
|
342
|
+
* factory passes init with Headers, which makes this the failure mode
|
|
343
|
+
* for every forwarder that relies on browser-supplied cookies reaching
|
|
344
|
+
* VTEX. Funneling all merges through the `Headers` constructor (which
|
|
345
|
+
* correctly absorbs every HeadersInit shape) keeps the bug from
|
|
346
|
+
* sneaking back in.
|
|
347
|
+
*/
|
|
348
|
+
function mergeHeaders(
|
|
349
|
+
auth: Record<string, string>,
|
|
350
|
+
segmentCookie: string | null,
|
|
351
|
+
callerHeaders: HeadersInit | undefined,
|
|
352
|
+
): Headers {
|
|
353
|
+
const merged = new Headers(auth);
|
|
354
|
+
if (segmentCookie) merged.set("cookie", segmentCookie);
|
|
355
|
+
if (callerHeaders) {
|
|
356
|
+
const incoming = new Headers(callerHeaders);
|
|
357
|
+
incoming.forEach((value, key) => {
|
|
358
|
+
merged.set(key, value);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return merged;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function vtexFetch<T>(path: string, init?: InstrumentedFetchInit): Promise<T> {
|
|
365
|
+
const response = await vtexFetchResponse(path, init);
|
|
366
|
+
return response.json();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface VtexCachedFetchOptions {
|
|
370
|
+
/** SWR cache TTL override in ms */
|
|
371
|
+
cacheTTL?: number;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Like vtexFetch but routes GET requests through the SWR in-memory cache.
|
|
376
|
+
* Uses in-flight dedup + stale-while-revalidate.
|
|
377
|
+
* Non-GET requests fall through to regular vtexFetch.
|
|
378
|
+
*/
|
|
379
|
+
export async function vtexCachedFetch<T>(
|
|
380
|
+
path: string,
|
|
381
|
+
init?: InstrumentedFetchInit,
|
|
382
|
+
cacheOpts?: VtexCachedFetchOptions,
|
|
383
|
+
): Promise<T | null> {
|
|
384
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
385
|
+
if (method !== "GET") return vtexFetch<T>(path, init);
|
|
386
|
+
|
|
387
|
+
const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
|
|
388
|
+
const url = sanitizeUrl(raw);
|
|
389
|
+
const opts: FetchCacheOptions | undefined = cacheOpts?.cacheTTL
|
|
390
|
+
? { ttl: cacheOpts.cacheTTL }
|
|
391
|
+
: undefined;
|
|
392
|
+
|
|
393
|
+
// Mirrors vtexFetchResponse: Legacy Catalog and several other GET
|
|
394
|
+
// endpoints gate regional seller availability on the `vtex_segment`
|
|
395
|
+
// cookie. Cached GETs (PDP / shelf product lookups) must see the same
|
|
396
|
+
// regionalization the rest of the stack does — otherwise sites have
|
|
397
|
+
// to wrap _fetch themselves to forward the cookie, which is easy to
|
|
398
|
+
// get subtly wrong (especially around HeadersInit shapes). Inline
|
|
399
|
+
// here keeps the surface small; if a third callsite appears we
|
|
400
|
+
// extract a shared helper.
|
|
401
|
+
const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
|
|
402
|
+
|
|
403
|
+
return fetchWithCache<T>(
|
|
404
|
+
url,
|
|
405
|
+
() =>
|
|
406
|
+
_fetch(url, {
|
|
407
|
+
...init,
|
|
408
|
+
headers: mergeHeaders(authHeaders(), segmentCookie, init?.headers),
|
|
409
|
+
}),
|
|
410
|
+
opts,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Like vtexFetch, but also forwards Set-Cookie headers via RequestContext.
|
|
416
|
+
* Use for checkout, session, and auth actions that set cookies.
|
|
417
|
+
*
|
|
418
|
+
* Cookie propagation happens automatically:
|
|
419
|
+
* - Reads the browser's Cookie header from RequestContext.request
|
|
420
|
+
* - Writes upstream Set-Cookie headers to RequestContext.responseHeaders
|
|
421
|
+
* - The invoke handler copies responseHeaders into the HTTP Response
|
|
422
|
+
*
|
|
423
|
+
* This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
|
|
424
|
+
*/
|
|
425
|
+
export async function vtexFetchWithCookies<T>(
|
|
426
|
+
path: string,
|
|
427
|
+
init?: InstrumentedFetchInit,
|
|
428
|
+
): Promise<T> {
|
|
429
|
+
// Auto-inject request cookies from RequestContext.
|
|
430
|
+
//
|
|
431
|
+
// We sanitize the forwarded Cookie header before sending it to VTEX:
|
|
432
|
+
// the janus gateway returns 503 (empty body) on any cookie value that
|
|
433
|
+
// isn't strict ASCII per RFC 6265. Third-party analytics tags that write
|
|
434
|
+
// raw UTF-8 into document.cookie (e.g. category names with accents) will
|
|
435
|
+
// otherwise poison every checkout call for the affected user. The drop
|
|
436
|
+
// report is emitted via warnDroppedCookies() so we have observability the
|
|
437
|
+
// next time a tag misbehaves.
|
|
438
|
+
//
|
|
439
|
+
// Headers normalisation: callers pass either Headers, [name,value][],
|
|
440
|
+
// or Record<string,string>. We must NEVER spread a Headers instance as
|
|
441
|
+
// a plain object — it collapses to {} and silently drops every other
|
|
442
|
+
// header the caller set (auth, content-type, etc.). withCookieHeader()
|
|
443
|
+
// funnels every shape through the Headers constructor and is the only
|
|
444
|
+
// safe way to rewrite the cookie value.
|
|
445
|
+
const callerCookie = readCookieHeader(init?.headers);
|
|
446
|
+
if (!callerCookie) {
|
|
447
|
+
const ctx = RequestContext.current;
|
|
448
|
+
const raw = ctx?.request.headers.get("cookie");
|
|
449
|
+
if (raw) {
|
|
450
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(raw);
|
|
451
|
+
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
|
|
452
|
+
if (cookies) {
|
|
453
|
+
init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
// Caller passed an explicit cookie — sanitize it too.
|
|
458
|
+
const { cookies, dropped } = sanitizeOutboundCookieHeader(callerCookie);
|
|
459
|
+
if (dropped.length) warnDroppedCookies(dropped, vtexHost());
|
|
460
|
+
init = { ...init, headers: withCookieHeader(init?.headers, cookies) };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const response = await vtexFetchResponse(path, init);
|
|
464
|
+
const data = (await response.json()) as T;
|
|
465
|
+
|
|
466
|
+
// Forward Set-Cookie headers to RequestContext.responseHeaders,
|
|
467
|
+
// but skip VTEX internal IS cookies (managed server-side by the middleware).
|
|
468
|
+
const responseHeaders = getResponseHeaders();
|
|
469
|
+
if (responseHeaders) {
|
|
470
|
+
const host = getRequestHost();
|
|
471
|
+
const setCookies =
|
|
472
|
+
typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [];
|
|
473
|
+
for (const cookie of setCookies) {
|
|
474
|
+
if (cookie.startsWith(`${SESSION_COOKIE}=`) || cookie.startsWith(`${ANONYMOUS_COOKIE}=`)) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
responseHeaders.append("set-cookie", rewriteCookieDomain(cookie, host));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return data;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export async function intelligentSearch<T>(
|
|
485
|
+
path: string,
|
|
486
|
+
params?: Record<string, string>,
|
|
487
|
+
opts?: { cookieHeader?: string; locale?: string; regionId?: string },
|
|
488
|
+
): Promise<T> {
|
|
489
|
+
const url = new URL(`${isUrl()}${path}`);
|
|
490
|
+
if (params) {
|
|
491
|
+
for (const [k, v] of Object.entries(params)) {
|
|
492
|
+
url.searchParams.set(k, v);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
const c = getVtexConfig();
|
|
496
|
+
if (c.salesChannel) url.searchParams.set("sc", c.salesChannel);
|
|
497
|
+
|
|
498
|
+
const locale = opts?.locale ?? c.locale;
|
|
499
|
+
if (locale && !url.searchParams.has("locale")) {
|
|
500
|
+
url.searchParams.set("locale", locale);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const regionId = opts?.regionId ?? extractRegionIdFromCookies();
|
|
504
|
+
if (regionId) {
|
|
505
|
+
url.searchParams.set("regionId", regionId);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const headers: Record<string, string> = { ...authHeaders() };
|
|
509
|
+
if (opts?.cookieHeader) {
|
|
510
|
+
headers.cookie = opts.cookieHeader;
|
|
511
|
+
} else {
|
|
512
|
+
// IS already gets regionId on the query string above, but some
|
|
513
|
+
// internal IS flows (and downstream services it consults) still
|
|
514
|
+
// honor the `vtex_segment` cookie — forward it when the caller
|
|
515
|
+
// didn't pass an explicit one. See vtexCachedFetch for the same
|
|
516
|
+
// rationale.
|
|
517
|
+
const segmentCookie = getSegmentCookieHeader();
|
|
518
|
+
if (segmentCookie) headers.cookie = segmentCookie;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const fullUrl = url.toString();
|
|
522
|
+
|
|
523
|
+
return fetchWithCache<T>(fullUrl, async () => {
|
|
524
|
+
const response = await _fetch(fullUrl, { headers });
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
throw new Error(`VTEX IS error: ${response.status} - ${fullUrl}`);
|
|
527
|
+
}
|
|
528
|
+
return response;
|
|
529
|
+
}) as Promise<T>;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Execute a GraphQL query against the VTEX IO Runtime (myvtex.com).
|
|
534
|
+
* Used for private profile/session/wishlist/payment queries that the
|
|
535
|
+
* original Deco loaders called via `ctx.io.query(...)`.
|
|
536
|
+
*/
|
|
537
|
+
export async function vtexIOGraphQL<T>(
|
|
538
|
+
body: {
|
|
539
|
+
query: string;
|
|
540
|
+
variables?: Record<string, unknown> | null;
|
|
541
|
+
operationName?: string;
|
|
542
|
+
},
|
|
543
|
+
headers?: Record<string, string>,
|
|
544
|
+
): Promise<T> {
|
|
545
|
+
const { account } = getVtexConfig();
|
|
546
|
+
const res = await vtexFetch<{ data: T; errors?: Array<{ message: string }> }>(
|
|
547
|
+
`https://${account}.myvtex.com/_v/private/graphql/v1`,
|
|
548
|
+
{
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers,
|
|
551
|
+
body: JSON.stringify(body),
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
if (res.errors?.length) {
|
|
555
|
+
throw new Error(`VTEX IO GraphQL error: ${res.errors.map((e) => e.message).join(", ")}`);
|
|
556
|
+
}
|
|
557
|
+
return res.data;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// -- Page Type API (used by PLP to derive category facets from URL path) --
|
|
561
|
+
|
|
562
|
+
export interface PageType {
|
|
563
|
+
id: string;
|
|
564
|
+
name: string;
|
|
565
|
+
url: string;
|
|
566
|
+
title: string;
|
|
567
|
+
metaTagDescription: string;
|
|
568
|
+
pageType:
|
|
569
|
+
| "Brand"
|
|
570
|
+
| "Category"
|
|
571
|
+
| "Department"
|
|
572
|
+
| "SubCategory"
|
|
573
|
+
| "Collection"
|
|
574
|
+
| "Cluster"
|
|
575
|
+
| "Search"
|
|
576
|
+
| "Product"
|
|
577
|
+
| "NotFound"
|
|
578
|
+
| "FullText";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const PAGE_TYPE_TO_MAP_PARAM: Record<string, string | null> = {
|
|
582
|
+
Brand: "brand",
|
|
583
|
+
Collection: "productClusterIds",
|
|
584
|
+
Cluster: "productClusterIds",
|
|
585
|
+
Search: null,
|
|
586
|
+
Product: null,
|
|
587
|
+
NotFound: null,
|
|
588
|
+
FullText: null,
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
function pageTypeToMapParam(type: PageType["pageType"], index: number): string | null {
|
|
592
|
+
if (type === "Category" || type === "Department" || type === "SubCategory") {
|
|
593
|
+
return `category-${index + 1}`;
|
|
594
|
+
}
|
|
595
|
+
return PAGE_TYPE_TO_MAP_PARAM[type] ?? null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function cachedPageType(term: string): Promise<PageType | null> {
|
|
599
|
+
return vtexCachedFetch<PageType>(`/api/catalog_system/pub/portal/pagetype/${term}`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Query VTEX Page Type API for each path segment (cumulative).
|
|
604
|
+
* Mirrors deco-cx/apps `pageTypesFromUrl`.
|
|
605
|
+
* Uses in-flight deduplication to avoid duplicate calls for the same segment.
|
|
606
|
+
*/
|
|
607
|
+
export async function pageTypesFromPath(pagePath: string): Promise<PageType[]> {
|
|
608
|
+
const segments = pagePath.split("/").filter(Boolean);
|
|
609
|
+
const results = await Promise.all(
|
|
610
|
+
segments.map((_, index) => {
|
|
611
|
+
const term = segments.slice(0, index + 1).join("/");
|
|
612
|
+
return cachedPageType(term);
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
return results.filter((pt): pt is PageType => pt !== null);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const slugify = (str: string) =>
|
|
619
|
+
str
|
|
620
|
+
.replace(/,/g, "")
|
|
621
|
+
.replace(/[·/_,:]/g, "-")
|
|
622
|
+
.replace(/[*+~.()'"!:@&[\]`/ %$#?{}|><=_^]/g, "-")
|
|
623
|
+
.normalize("NFD")
|
|
624
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
625
|
+
.toLowerCase();
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Convert page types to selectedFacets with correct IS facet keys.
|
|
629
|
+
* Mirrors deco-cx/apps `filtersFromPathname`.
|
|
630
|
+
*/
|
|
631
|
+
export function filtersFromPageTypes(pageTypes: PageType[]): Array<{ key: string; value: string }> {
|
|
632
|
+
return pageTypes
|
|
633
|
+
.map((page, index) => {
|
|
634
|
+
const key = pageTypeToMapParam(page.pageType, index);
|
|
635
|
+
if (!key || !page.name) return null;
|
|
636
|
+
return { key, value: slugify(page.name) };
|
|
637
|
+
})
|
|
638
|
+
.filter((f): f is { key: string; value: string } => f !== null);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Build the IS facet path string from selectedFacets.
|
|
643
|
+
* Mirrors deco-cx/apps `toPath`.
|
|
644
|
+
*/
|
|
645
|
+
export function toFacetPath(facets: Array<{ key: string; value: string }>): string {
|
|
646
|
+
return facets.map(({ key, value }) => (key ? `${key}/${value}` : value)).join("/");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
export function initVtexFromBlocks(blocks: Record<string, any>) {
|
|
650
|
+
const vtexBlock = blocks.vtex || blocks["deco-vtex"];
|
|
651
|
+
if (!vtexBlock) {
|
|
652
|
+
console.warn("[VTEX] No vtex.json block found.");
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const appKey = typeof vtexBlock.appKey === "string" ? vtexBlock.appKey : undefined;
|
|
656
|
+
const appToken = typeof vtexBlock.appToken === "string" ? vtexBlock.appToken : undefined;
|
|
657
|
+
configureVtex({
|
|
658
|
+
account: vtexBlock.account,
|
|
659
|
+
publicUrl: vtexBlock.publicUrl,
|
|
660
|
+
salesChannel: vtexBlock.salesChannel || "1",
|
|
661
|
+
locale: vtexBlock.locale || vtexBlock.defaultLocale,
|
|
662
|
+
appKey,
|
|
663
|
+
appToken,
|
|
664
|
+
country: vtexBlock.country,
|
|
665
|
+
domain: vtexBlock.domain,
|
|
666
|
+
});
|
|
667
|
+
}
|