@decocms/apps 1.6.4 → 1.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.6.4",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -116,7 +116,7 @@
116
116
  },
117
117
  "devDependencies": {
118
118
  "@biomejs/biome": "^2.4.7",
119
- "@decocms/start": "^0.38.0",
119
+ "@decocms/start": "^2.5.0",
120
120
  "@semantic-release/exec": "^7.1.0",
121
121
  "@semantic-release/git": "^10.0.1",
122
122
  "@tanstack/react-query": "^5.90.21",
package/vtex/client.ts CHANGED
@@ -9,22 +9,15 @@ import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "./utils/intelligentSearch";
9
9
  import { parseSegment, SEGMENT_COOKIE_NAME } from "./utils/segment";
10
10
 
11
11
  /**
12
- * Get the response headers from RequestContext.
13
- * Uses `responseHeaders` when available (@decocms/start PR#57),
14
- * falls back to the bag with a lazily-created Headers instance.
15
- * TODO: Remove fallback once @decocms/start PR#57 is published.
12
+ * Outgoing response headers for the active request, or `null` when
13
+ * called outside a request scope (which happens during module init).
14
+ * `RequestContext.responseHeaders` was added to `@decocms/start` in
15
+ * v0.39.0; we now require >=2.5.0 as a devDep so the property is
16
+ * always typed/present.
16
17
  */
17
18
  function getResponseHeaders(): Headers | null {
18
19
  const ctx = RequestContext.current;
19
- if (!ctx) return null;
20
- // biome-ignore lint/suspicious/noExplicitAny: forward-compat with upcoming responseHeaders property
21
- if ((ctx as any).responseHeaders instanceof Headers) return (ctx as any).responseHeaders;
22
- let headers = ctx.bag.get("responseHeaders") as Headers | undefined;
23
- if (!headers) {
24
- headers = new Headers();
25
- ctx.bag.set("responseHeaders", headers);
26
- }
27
- return headers;
20
+ return ctx ? ctx.responseHeaders : null;
28
21
  }
29
22
 
30
23
  // ---------------------------------------------------------------------------
@@ -175,12 +168,55 @@ function extractRegionIdFromCookies(): string | null {
175
168
  return segment?.regionId ?? null;
176
169
  }
177
170
 
171
+ /**
172
+ * Read the raw `vtex_segment=<token>` cookie from the active request.
173
+ * Returns null when outside a request context or no segment cookie is set.
174
+ *
175
+ * Used to forward the segment cookie on outgoing VTEX API calls so
176
+ * Legacy Catalog endpoints (which gate on the cookie, not on
177
+ * `?regionId=` query params) see the right region for products
178
+ * available only through regional sellers.
179
+ */
180
+ function getSegmentCookieHeader(): string | null {
181
+ const ctx = RequestContext.current;
182
+ if (!ctx) return null;
183
+ const cookies = ctx.request.headers.get("cookie");
184
+ if (!cookies) return null;
185
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${SEGMENT_COOKIE_NAME}=([^;]+)`));
186
+ if (!match?.[1]) return null;
187
+ return `${SEGMENT_COOKIE_NAME}=${match[1]}`;
188
+ }
189
+
190
+ /** Case-insensitive lookup for `cookie` / `Cookie` in a headers init. */
191
+ function hasCookieHeader(headers: HeadersInit | undefined): boolean {
192
+ if (!headers) return false;
193
+ if (headers instanceof Headers) return headers.has("cookie");
194
+ if (Array.isArray(headers)) {
195
+ return headers.some(([k]) => k.toLowerCase() === "cookie");
196
+ }
197
+ return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
198
+ }
199
+
178
200
  export async function vtexFetchResponse(path: string, init?: RequestInit): Promise<Response> {
179
201
  const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
180
202
  const url = sanitizeUrl(raw);
203
+
204
+ // Forward the incoming `vtex_segment` cookie on outgoing calls when
205
+ // the caller hasn't set a cookie header explicitly. This is what the
206
+ // Legacy Catalog API (and a handful of other VTEX endpoints) needs
207
+ // to resolve regional sellers correctly. Without it, products only
208
+ // available via a region's seller appear as OutOfStock on PDPs even
209
+ // for users with the cookie. Sites used to wrap `_fetch` themselves
210
+ // to do this — see https://github.com/decocms/apps-start#regional-sellers
211
+ const segmentCookie = !hasCookieHeader(init?.headers) ? getSegmentCookieHeader() : null;
212
+
181
213
  const response = await _fetch(url, {
182
214
  ...init,
183
- headers: { ...authHeaders(), ...init?.headers },
215
+ headers: {
216
+ ...authHeaders(),
217
+ ...(segmentCookie ? { cookie: segmentCookie } : {}),
218
+ ...init?.headers,
219
+ },
184
220
  });
185
221
  if (!response.ok) {
186
222
  throw new Error(`VTEX API error: ${response.status} ${response.statusText} - ${url}`);
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Factory for the legacy invoke-based `useCart` hook.
3
+ *
4
+ * This is the API shape that migrated Fresh sites depend on:
5
+ * - module-level singleton state (no QueryClient required)
6
+ * - listener-based re-render (`forceRender` on a useState counter)
7
+ * - awaitable async actions (`await addItem(...)`) instead of TanStack mutations
8
+ * - signal-shaped accessors (`cart.value`, `cart.value = ...`)
9
+ *
10
+ * It is intentionally separate from the canonical `useCart` in
11
+ * `vtex/hooks/useCart.ts`, which is built on TanStack Query and exposes the
12
+ * `Minicart` shape. Both can coexist in a single site.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * // src/hooks/useCart.ts
17
+ * import { createUseCart } from "@decocms/apps/vtex/hooks/createUseCart";
18
+ * import { invoke } from "~/server/invoke";
19
+ *
20
+ * export const { useCart, resetCart, itemToAnalyticsItem } = createUseCart({
21
+ * invoke,
22
+ * });
23
+ * export type { OrderForm, OrderFormItem } from "@decocms/apps/vtex/types";
24
+ * ```
25
+ */
26
+
27
+ import { useEffect, useState } from "react";
28
+ import type { OrderForm, OrderFormItem } from "../types";
29
+
30
+ /** Minimal structural shape of the invoke proxy this hook needs. */
31
+ export interface CreateUseCartInvoke {
32
+ vtex: {
33
+ actions: {
34
+ getOrCreateCart: (args: {
35
+ data: { orderFormId?: string };
36
+ }) => Promise<OrderForm>;
37
+ addItemsToCart: (args: {
38
+ data: {
39
+ orderFormId: string;
40
+ orderItems: Array<{ id: string; seller: string; quantity: number }>;
41
+ };
42
+ }) => Promise<OrderForm>;
43
+ updateCartItems: (args: {
44
+ data: {
45
+ orderFormId: string;
46
+ orderItems: Array<{ index: number; quantity: number }>;
47
+ };
48
+ }) => Promise<OrderForm>;
49
+ addCouponToCart: (args: {
50
+ data: { orderFormId: string; text: string };
51
+ }) => Promise<OrderForm>;
52
+ updateOrderFormAttachment: (args: {
53
+ data: {
54
+ orderFormId: string;
55
+ attachment: string;
56
+ body: Record<string, unknown>;
57
+ };
58
+ }) => Promise<OrderForm>;
59
+ simulateCart: (args: {
60
+ data: {
61
+ items: Array<{ id: string; quantity: number; seller: string }>;
62
+ postalCode: string;
63
+ country: string;
64
+ };
65
+ }) => Promise<unknown>;
66
+ };
67
+ };
68
+ }
69
+
70
+ export interface CreateUseCartOptions {
71
+ invoke: CreateUseCartInvoke;
72
+ /**
73
+ * Override the orderFormId cookie name. VTEX standard is
74
+ * `checkout.vtex.com__orderFormId`, which is the default.
75
+ */
76
+ orderFormCookieName?: string;
77
+ /** Override the cookie max-age in seconds. Default: 7 days. */
78
+ orderFormCookieMaxAge?: number;
79
+ }
80
+
81
+ /** Build a per-site `useCart` plus its companions. */
82
+ export function createUseCart(opts: CreateUseCartOptions) {
83
+ const { invoke } = opts;
84
+ const COOKIE_NAME = opts.orderFormCookieName ?? "checkout.vtex.com__orderFormId";
85
+ const COOKIE_MAX_AGE = opts.orderFormCookieMaxAge ?? 7 * 24 * 3600;
86
+
87
+ let _orderForm: OrderForm | null = null;
88
+ let _loading = false;
89
+ let _initStarted = false;
90
+ let _initFailed = false;
91
+ const _listeners = new Set<() => void>();
92
+
93
+ function notify() {
94
+ for (const fn of _listeners) fn();
95
+ }
96
+ function setOrderForm(of: OrderForm | null) {
97
+ _orderForm = of;
98
+ notify();
99
+ }
100
+ function setLoading(v: boolean) {
101
+ _loading = v;
102
+ notify();
103
+ }
104
+
105
+ function escapeRegex(s: string): string {
106
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
+ }
108
+
109
+ function getOrderFormIdFromCookie(): string | null {
110
+ if (typeof document === "undefined") return null;
111
+ const re = new RegExp(`${escapeRegex(COOKIE_NAME)}=([^;]*)`);
112
+ const match = document.cookie.match(re);
113
+ return match ? decodeURIComponent(match[1]) : null;
114
+ }
115
+
116
+ function setOrderFormIdCookie(id: string) {
117
+ if (typeof document === "undefined") return;
118
+ document.cookie = `${COOKIE_NAME}=${encodeURIComponent(id)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
119
+ }
120
+
121
+ async function ensureOrderForm(): Promise<string> {
122
+ if (_orderForm?.orderFormId) return _orderForm.orderFormId;
123
+
124
+ const existing = getOrderFormIdFromCookie();
125
+ const of = await invoke.vtex.actions.getOrCreateCart({
126
+ data: { orderFormId: existing || undefined },
127
+ });
128
+ setOrderForm(of);
129
+ if (of?.orderFormId) setOrderFormIdCookie(of.orderFormId);
130
+ return of.orderFormId;
131
+ }
132
+
133
+ function itemToAnalyticsItem(
134
+ item: OrderFormItem & { coupon?: string },
135
+ index: number,
136
+ ) {
137
+ return {
138
+ item_id: item.productId,
139
+ item_group_id: item.productId,
140
+ item_name: item.name ?? item.skuName ?? "",
141
+ item_variant: item.skuName,
142
+ item_brand: item.additionalInfo?.brandName ?? "",
143
+ price: (item.sellingPrice ?? item.price ?? 0) / 100,
144
+ discount: Number(((item.listPrice - item.sellingPrice) / 100).toFixed(2)),
145
+ quantity: item.quantity,
146
+ coupon: item.coupon,
147
+ affiliation: item.seller,
148
+ index,
149
+ };
150
+ }
151
+
152
+ /** Reset all module-level cart state so the next useCart() re-fetches. */
153
+ function resetCart() {
154
+ _orderForm = null;
155
+ _loading = false;
156
+ _initStarted = false;
157
+ _initFailed = false;
158
+ notify();
159
+ }
160
+
161
+ function useCart() {
162
+ const [, forceRender] = useState(0);
163
+
164
+ useEffect(() => {
165
+ const listener = () => forceRender((n) => n + 1);
166
+ _listeners.add(listener);
167
+
168
+ if (!_orderForm && !_initStarted) {
169
+ _initStarted = true;
170
+ const ofId = getOrderFormIdFromCookie();
171
+ setLoading(true);
172
+ invoke.vtex.actions
173
+ .getOrCreateCart({ data: { orderFormId: ofId || undefined } })
174
+ .then((of) => {
175
+ setOrderForm(of);
176
+ if (of?.orderFormId) setOrderFormIdCookie(of.orderFormId);
177
+ })
178
+ .catch((err: unknown) => {
179
+ console.error("[useCart] init failed:", err);
180
+ // Keep previous orderForm if we had one (e.g. after SPA navigation)
181
+ // so user data isn't lost on transient VTEX 503s.
182
+ if (!_orderForm) {
183
+ _initFailed = true;
184
+ notify();
185
+ }
186
+ })
187
+ .finally(() => setLoading(false));
188
+ }
189
+
190
+ return () => {
191
+ _listeners.delete(listener);
192
+ };
193
+ }, []);
194
+
195
+ return {
196
+ cart: {
197
+ get value() {
198
+ return _orderForm;
199
+ },
200
+ set value(v: OrderForm | null) {
201
+ setOrderForm(v);
202
+ },
203
+ },
204
+
205
+ loading: {
206
+ get value() {
207
+ return _loading;
208
+ },
209
+ set value(v: boolean) {
210
+ setLoading(v);
211
+ },
212
+ },
213
+
214
+ initFailed: {
215
+ get value() {
216
+ return _initFailed;
217
+ },
218
+ },
219
+
220
+ addItem: async (params: {
221
+ id: string;
222
+ seller: string;
223
+ quantity?: number;
224
+ }) => {
225
+ setLoading(true);
226
+ try {
227
+ const ofId = await ensureOrderForm();
228
+ const updated = await invoke.vtex.actions.addItemsToCart({
229
+ data: {
230
+ orderFormId: ofId,
231
+ orderItems: [
232
+ {
233
+ id: params.id,
234
+ seller: params.seller,
235
+ quantity: params.quantity ?? 1,
236
+ },
237
+ ],
238
+ },
239
+ });
240
+ setOrderForm(updated);
241
+ if (updated?.orderFormId) setOrderFormIdCookie(updated.orderFormId);
242
+ } catch (err) {
243
+ console.error("[useCart] addItem failed:", err);
244
+ throw err;
245
+ } finally {
246
+ setLoading(false);
247
+ }
248
+ },
249
+
250
+ addItems: async (params: {
251
+ orderItems: Array<{ id: string; seller: string; quantity: number }>;
252
+ }) => {
253
+ setLoading(true);
254
+ try {
255
+ const ofId = await ensureOrderForm();
256
+ const updated = await invoke.vtex.actions.addItemsToCart({
257
+ data: { orderFormId: ofId, orderItems: params.orderItems },
258
+ });
259
+ setOrderForm(updated);
260
+ if (updated?.orderFormId) setOrderFormIdCookie(updated.orderFormId);
261
+ } catch (err) {
262
+ console.error("[useCart] addItems failed:", err);
263
+ throw err;
264
+ } finally {
265
+ setLoading(false);
266
+ }
267
+ },
268
+
269
+ updateItems: async (params: {
270
+ orderItems: Array<{ index: number; quantity: number }>;
271
+ }) => {
272
+ const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
273
+ if (!ofId) return;
274
+ setLoading(true);
275
+ try {
276
+ const updated = await invoke.vtex.actions.updateCartItems({
277
+ data: { orderFormId: ofId, orderItems: params.orderItems },
278
+ });
279
+ setOrderForm(updated);
280
+ } catch (err) {
281
+ console.error("[useCart] updateItems failed:", err);
282
+ } finally {
283
+ setLoading(false);
284
+ }
285
+ },
286
+
287
+ removeItem: async (index: number) => {
288
+ const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
289
+ if (!ofId) return;
290
+ setLoading(true);
291
+ try {
292
+ const updated = await invoke.vtex.actions.updateCartItems({
293
+ data: {
294
+ orderFormId: ofId,
295
+ orderItems: [{ index, quantity: 0 }],
296
+ },
297
+ });
298
+ setOrderForm(updated);
299
+ } catch (err) {
300
+ console.error("[useCart] removeItem failed:", err);
301
+ } finally {
302
+ setLoading(false);
303
+ }
304
+ },
305
+
306
+ addCouponsToCart: async ({ text }: { text: string }) => {
307
+ const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
308
+ if (!ofId) return;
309
+ setLoading(true);
310
+ try {
311
+ const updated = await invoke.vtex.actions.addCouponToCart({
312
+ data: { orderFormId: ofId, text },
313
+ });
314
+ setOrderForm(updated);
315
+ } catch (err) {
316
+ console.error("[useCart] addCoupon failed:", err);
317
+ } finally {
318
+ setLoading(false);
319
+ }
320
+ },
321
+
322
+ sendAttachment: async (params: {
323
+ attachment: string;
324
+ body: Record<string, unknown>;
325
+ }) => {
326
+ const ofId = _orderForm?.orderFormId || getOrderFormIdFromCookie();
327
+ if (!ofId) return;
328
+ setLoading(true);
329
+ try {
330
+ const updated = await invoke.vtex.actions.updateOrderFormAttachment({
331
+ data: {
332
+ orderFormId: ofId,
333
+ attachment: params.attachment,
334
+ body: params.body,
335
+ },
336
+ });
337
+ setOrderForm(updated);
338
+ } catch (err) {
339
+ console.error("[useCart] sendAttachment failed:", err);
340
+ } finally {
341
+ setLoading(false);
342
+ }
343
+ },
344
+
345
+ simulate: async (data: {
346
+ items: Array<{ id: string; quantity: number; seller: string }>;
347
+ postalCode: string;
348
+ country: string;
349
+ }) => {
350
+ return await invoke.vtex.actions.simulateCart({
351
+ data: {
352
+ items: data.items.map((i) => ({
353
+ id: i.id,
354
+ quantity: i.quantity,
355
+ seller: i.seller,
356
+ })),
357
+ postalCode: data.postalCode,
358
+ country: data.country,
359
+ },
360
+ });
361
+ },
362
+
363
+ mapItemsToAnalyticsItems: (orderForm: OrderForm | null) => {
364
+ return (orderForm?.items || []).map((item, index) =>
365
+ itemToAnalyticsItem(item, index),
366
+ );
367
+ },
368
+ };
369
+ }
370
+
371
+ return {
372
+ useCart,
373
+ resetCart,
374
+ itemToAnalyticsItem,
375
+ };
376
+ }
@@ -1,4 +1,9 @@
1
1
  export { type UseAutocompleteOptions, useAutocomplete } from "./useAutocomplete";
2
2
  export { type CartItem, type OrderForm, type UseCartOptions, useCart } from "./useCart";
3
+ export {
4
+ type CreateUseCartInvoke,
5
+ type CreateUseCartOptions,
6
+ createUseCart,
7
+ } from "./createUseCart";
3
8
  export { type UseUserOptions, useUser, type VtexUser } from "./useUser";
4
9
  export { type UseWishlistOptions, useWishlist, type WishlistItem } from "./useWishlist";