@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.
Files changed (109) hide show
  1. package/package.json +67 -0
  2. package/src/README.md +6 -0
  3. package/src/__tests__/client-segment-cookie.test.ts +255 -0
  4. package/src/__tests__/client-set-cookie-forward.test.ts +257 -0
  5. package/src/actions/address.ts +250 -0
  6. package/src/actions/analytics/sendEvent.ts +86 -0
  7. package/src/actions/auth.ts +333 -0
  8. package/src/actions/checkout.ts +522 -0
  9. package/src/actions/index.ts +11 -0
  10. package/src/actions/masterData.ts +168 -0
  11. package/src/actions/misc.ts +188 -0
  12. package/src/actions/newsletter.ts +105 -0
  13. package/src/actions/orders.ts +34 -0
  14. package/src/actions/profile.ts +201 -0
  15. package/src/actions/session.ts +85 -0
  16. package/src/actions/trigger.ts +43 -0
  17. package/src/actions/wishlist.ts +114 -0
  18. package/src/client.ts +667 -0
  19. package/src/commerceLoaders.ts +261 -0
  20. package/src/hooks/__tests__/createUseCart.test.ts +184 -0
  21. package/src/hooks/__tests__/createUseUser.test.ts +48 -0
  22. package/src/hooks/__tests__/createUseWishlist.test.ts +87 -0
  23. package/src/hooks/createUseCart.ts +360 -0
  24. package/src/hooks/createUseUser.ts +153 -0
  25. package/src/hooks/createUseWishlist.ts +242 -0
  26. package/src/hooks/index.ts +19 -0
  27. package/src/hooks/useAutocomplete.ts +83 -0
  28. package/src/hooks/useCart.ts +243 -0
  29. package/src/hooks/useUser.ts +78 -0
  30. package/src/hooks/useWishlist.ts +119 -0
  31. package/src/index.ts +17 -0
  32. package/src/invoke.ts +181 -0
  33. package/src/loaders/ProductDetailsPage.ts +1 -0
  34. package/src/loaders/ProductList.ts +1 -0
  35. package/src/loaders/ProductListingPage.ts +1 -0
  36. package/src/loaders/address.ts +115 -0
  37. package/src/loaders/autocomplete.ts +58 -0
  38. package/src/loaders/brands.ts +44 -0
  39. package/src/loaders/cart.ts +52 -0
  40. package/src/loaders/catalog.ts +163 -0
  41. package/src/loaders/collections.ts +55 -0
  42. package/src/loaders/index.ts +19 -0
  43. package/src/loaders/intelligentSearch/__tests__/productListingPage.test.ts +20 -0
  44. package/src/loaders/intelligentSearch/productDetailsPage.ts +95 -0
  45. package/src/loaders/intelligentSearch/productList.ts +154 -0
  46. package/src/loaders/intelligentSearch/productListingPage.ts +506 -0
  47. package/src/loaders/intelligentSearch/suggestions.ts +46 -0
  48. package/src/loaders/legacy/productDetailsPage.ts +1 -0
  49. package/src/loaders/legacy/productList.ts +1 -0
  50. package/src/loaders/legacy/relatedProductsLoader.ts +81 -0
  51. package/src/loaders/legacy.ts +635 -0
  52. package/src/loaders/logistics.ts +111 -0
  53. package/src/loaders/minicart.ts +78 -0
  54. package/src/loaders/navbar.ts +26 -0
  55. package/src/loaders/orders.ts +92 -0
  56. package/src/loaders/pageType.ts +68 -0
  57. package/src/loaders/payment.ts +97 -0
  58. package/src/loaders/productListFull.ts +159 -0
  59. package/src/loaders/profile.ts +138 -0
  60. package/src/loaders/promotion.ts +30 -0
  61. package/src/loaders/search.ts +124 -0
  62. package/src/loaders/session.ts +83 -0
  63. package/src/loaders/user.ts +87 -0
  64. package/src/loaders/wishlist.ts +89 -0
  65. package/src/loaders/wishlistProducts.ts +69 -0
  66. package/src/loaders/workflow/products.ts +64 -0
  67. package/src/loaders/workflow.ts +316 -0
  68. package/src/logo.png +0 -0
  69. package/src/middleware.ts +240 -0
  70. package/src/mod.ts +152 -0
  71. package/src/registry.ts +9 -0
  72. package/src/types.ts +248 -0
  73. package/src/utils/__tests__/cookieSanitizer.test.ts +162 -0
  74. package/src/utils/__tests__/fetch.test.ts +80 -0
  75. package/src/utils/__tests__/fetchCache.test.ts +112 -0
  76. package/src/utils/__tests__/instrumentedFetch.test.ts +158 -0
  77. package/src/utils/__tests__/intelligentSearch.test.ts +88 -0
  78. package/src/utils/__tests__/minicart.test.ts +184 -0
  79. package/src/utils/__tests__/operationRouter.test.ts +227 -0
  80. package/src/utils/__tests__/resourceRange.test.ts +32 -0
  81. package/src/utils/__tests__/sitemap.test.ts +185 -0
  82. package/src/utils/__tests__/slugify.test.ts +31 -0
  83. package/src/utils/__tests__/transform.test.ts +698 -0
  84. package/src/utils/accountLoaders.ts +203 -0
  85. package/src/utils/authHelpers.ts +85 -0
  86. package/src/utils/batch.ts +18 -0
  87. package/src/utils/cookieSanitizer.ts +173 -0
  88. package/src/utils/cookies.ts +167 -0
  89. package/src/utils/enrichment.ts +560 -0
  90. package/src/utils/fetch.ts +107 -0
  91. package/src/utils/fetchCache.ts +222 -0
  92. package/src/utils/index.ts +19 -0
  93. package/src/utils/instrumentedFetch.ts +78 -0
  94. package/src/utils/intelligentSearch.ts +96 -0
  95. package/src/utils/legacy.ts +139 -0
  96. package/src/utils/minicart.ts +139 -0
  97. package/src/utils/operationRouter.ts +139 -0
  98. package/src/utils/pickAndOmit.ts +25 -0
  99. package/src/utils/proxy.ts +435 -0
  100. package/src/utils/resourceRange.ts +10 -0
  101. package/src/utils/segment.ts +152 -0
  102. package/src/utils/similars.ts +37 -0
  103. package/src/utils/sitemap.ts +282 -0
  104. package/src/utils/slugCache.ts +28 -0
  105. package/src/utils/slugify.ts +13 -0
  106. package/src/utils/transform.ts +1509 -0
  107. package/src/utils/types.ts +1884 -0
  108. package/src/utils/vtexId.ts +138 -0
  109. 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
+ }