@decocms/apps 1.4.1 → 1.6.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.
@@ -1096,3 +1096,133 @@ export type AnalyticsEvent =
1096
1096
  | ViewItemListEvent
1097
1097
  | ViewPromotionEvent
1098
1098
  | DecoEvent;
1099
+
1100
+ // ---------------------------------------------------------------------------
1101
+ // Minicart — platform-agnostic storefront cart contract
1102
+ // ---------------------------------------------------------------------------
1103
+ //
1104
+ // Reconciled superset of presentational shapes used across Deco storefronts.
1105
+ // Platform-specific extras (VTEX `attachments`, `seller`, etc.) live as
1106
+ // **optional** fields so future platform mappers (Shopify, VNDA, Wake, Linx,
1107
+ // Nuvemshop, ...) can populate-or-skip without breaking the contract.
1108
+ //
1109
+ // Pricing is in **major units** (e.g. `19.90` for R$19.90), not cents. This
1110
+ // matches `Intl.NumberFormat` (used by `commerce/sdk/formatPrice`) and lets
1111
+ // platform transforms convert from native units once at the boundary.
1112
+ //
1113
+ // Currently shipped with: VTEX (`vtex/utils/minicart.ts`, `vtex/inline-loaders/minicart.ts`).
1114
+
1115
+ /** Free-form attachment payload. Platforms attach customizations or services. */
1116
+ export interface MinicartItemAttachment {
1117
+ name: string;
1118
+ content: unknown;
1119
+ }
1120
+
1121
+ /** Description of an attachment slot offered by the platform for an item. */
1122
+ export interface MinicartItemAttachmentOffering {
1123
+ name: string;
1124
+ required: boolean;
1125
+ // biome-ignore lint/suspicious/noExplicitAny: schema is platform-specific
1126
+ schema?: any;
1127
+ }
1128
+
1129
+ /**
1130
+ * A single line in the minicart.
1131
+ *
1132
+ * Mirrors the fields of `AnalyticsItem` (GA4 contract) so a `MinicartItem` can
1133
+ * be forwarded directly to `add_to_cart` / `view_cart` events. We deliberately
1134
+ * make both `item_id` and `item_name` optional — `AnalyticsItem` enforces
1135
+ * "one or the other" via a discriminated union, which forces consumers to
1136
+ * narrow on every read. Storefront platforms typically populate both, and
1137
+ * sites cast at the GA4 boundary.
1138
+ *
1139
+ * Required:
1140
+ * - `image` — product image URL (storefront-hosted, https).
1141
+ * - `listPrice` — original list price per unit (compare-at), in major units.
1142
+ * - `price` — current selling price per unit, in major units.
1143
+ * - `quantity` — line quantity.
1144
+ *
1145
+ * Optional (platform-specific):
1146
+ * - `seller` — vendor / seller identifier (VTEX, Wake).
1147
+ * - `attachments` — applied customizations (VTEX).
1148
+ * - `attachmentOfferings` — offered customization slots (VTEX).
1149
+ */
1150
+ export interface MinicartItem {
1151
+ // --- GA4 analytics fields (compatible with `AnalyticsItem`) ---
1152
+ item_id?: string;
1153
+ item_name?: string;
1154
+ item_brand?: string;
1155
+ item_category?: string;
1156
+ item_category2?: string;
1157
+ item_category3?: string;
1158
+ item_category4?: string;
1159
+ item_category5?: string;
1160
+ item_group_id?: string;
1161
+ item_list_id?: string;
1162
+ item_list_name?: string;
1163
+ item_url?: string;
1164
+ item_variant?: string;
1165
+ affiliation?: string;
1166
+ coupon?: string;
1167
+ discount?: number;
1168
+ index?: number;
1169
+ location_id?: string;
1170
+
1171
+ // --- Cart-required fields ---
1172
+ /** Line quantity. */
1173
+ quantity: number;
1174
+ /** Product image URL (https). */
1175
+ image: string;
1176
+ /** Original list price per unit, in major units. */
1177
+ listPrice: number;
1178
+ /** Selling price per unit, in major units. */
1179
+ price: number;
1180
+
1181
+ // --- Platform-specific (optional) ---
1182
+ /** Vendor / seller identifier (VTEX, Wake). Optional for platforms without sellers. */
1183
+ seller?: string;
1184
+ /** Applied customizations on this line (VTEX attachments). */
1185
+ attachments?: MinicartItemAttachment[];
1186
+ /** Customization slots offered for this line (VTEX). */
1187
+ attachmentOfferings?: MinicartItemAttachmentOffering[];
1188
+ }
1189
+
1190
+ /**
1191
+ * Storefront-facing minicart. Two views:
1192
+ * - `original` — raw platform cart (VTEX OrderForm, Shopify Cart, ...). Sites use
1193
+ * this as an escape hatch for platform-specific reads (GTM, pixels, custom
1194
+ * integrations). Generic param `TRaw` lets callers narrow the type.
1195
+ * - `storefront` — normalized contract every UI consumes.
1196
+ *
1197
+ * All monetary values are in **major units** (decimal), not cents.
1198
+ */
1199
+ export interface Minicart<TRaw = unknown> {
1200
+ /** Raw platform cart blob — escape hatch for site-specific reads. */
1201
+ original: TRaw;
1202
+ /** Normalized storefront contract. */
1203
+ storefront: {
1204
+ items: MinicartItem[];
1205
+ /** Total payable, in major units. */
1206
+ total: number;
1207
+ /** Sum of line items before discounts/shipping, in major units. */
1208
+ subtotal: number;
1209
+ /** Total discount applied, in major units. Always non-negative. */
1210
+ discounts: number;
1211
+ /** Shipping cost, in major units. Undefined when not yet calculated. */
1212
+ shipping?: number;
1213
+ /** Applied coupon code, if any. */
1214
+ coupon?: string;
1215
+ /** BCP-47 locale (e.g. `"pt-BR"`). Drives `formatPrice`. */
1216
+ locale: string;
1217
+ /** ISO-4217 currency code (e.g. `"BRL"`). */
1218
+ currency: string;
1219
+ /** Whether the UI should expose the coupon input. */
1220
+ enableCoupon?: boolean;
1221
+ /** Free-shipping threshold in major units. Drives the progress bar. `0` disables it. */
1222
+ freeShippingTarget: number;
1223
+ /** Where the checkout button sends the user. */
1224
+ checkoutHref: string;
1225
+ /** Postal code used for shipping simulation. */
1226
+ postalCode?: string;
1227
+ };
1228
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -29,6 +29,7 @@
29
29
  "./vtex/types": "./vtex/types.ts",
30
30
  "./vtex/actions": "./vtex/actions/index.ts",
31
31
  "./vtex/actions/*": "./vtex/actions/*.ts",
32
+ "./vtex/actions/analytics/*": "./vtex/actions/analytics/*.ts",
32
33
  "./vtex/loaders": "./vtex/loaders/index.ts",
33
34
  "./vtex/loaders/*": "./vtex/loaders/*.ts",
34
35
  "./vtex/utils": "./vtex/utils/index.ts",
@@ -40,6 +41,7 @@
40
41
  "./vtex/inline-loaders/productListShelf": "./vtex/inline-loaders/productListShelf.ts",
41
42
  "./vtex/inline-loaders/relatedProducts": "./vtex/inline-loaders/relatedProducts.ts",
42
43
  "./vtex/inline-loaders/suggestions": "./vtex/inline-loaders/suggestions.ts",
44
+ "./vtex/inline-loaders/minicart": "./vtex/inline-loaders/minicart.ts",
43
45
  "./vtex/hooks": "./vtex/hooks/index.ts",
44
46
  "./vtex/hooks/*": "./vtex/hooks/*.ts",
45
47
  "./vtex/inline-loaders/workflowProducts": "./vtex/inline-loaders/workflowProducts.ts",
@@ -0,0 +1,85 @@
1
+ /**
2
+ * VTEX Intelligent Search analytics event.
3
+ *
4
+ * Reads the IS session/anonymous cookies from the incoming request and POSTs
5
+ * an event to the IS event-api. The configured account is resolved via
6
+ * {@link getVtexConfig}.
7
+ *
8
+ * @see https://developers.vtex.com/docs/api-reference/intelligent-search-api#post-/event-api/v1/-account-/event
9
+ */
10
+
11
+ import { getVtexConfig } from "../../client";
12
+ import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "../../utils/intelligentSearch";
13
+
14
+ export type Props =
15
+ | {
16
+ type: "session.ping";
17
+ url: string;
18
+ }
19
+ | {
20
+ type: "page.cart";
21
+ products: { productId: string; quantity: number }[];
22
+ }
23
+ | {
24
+ type: "page.empty_cart";
25
+ // Empty array would serialize as an invalid JSON schema, so accept anything.
26
+ products: unknown;
27
+ }
28
+ | {
29
+ type: "page.confirmation";
30
+ order: string;
31
+ products: { productId: string; quantity: number; price: number }[];
32
+ }
33
+ | {
34
+ type: "search.click";
35
+ position: number;
36
+ text: string;
37
+ productId: string;
38
+ url: string;
39
+ }
40
+ | {
41
+ type: "search.query";
42
+ url: string;
43
+ text: string;
44
+ misspelled: boolean;
45
+ match: number;
46
+ operator: string;
47
+ locale: string;
48
+ };
49
+
50
+ const readCookie = (cookieHeader: string, name: string): string | undefined => {
51
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${escaped}=([^;]+)`));
53
+ return match?.[1];
54
+ };
55
+
56
+ /**
57
+ * @title Send Analytics Event
58
+ * @description POST a VTEX Intelligent Search analytics event for the current session.
59
+ */
60
+ const action = async (props: Props, req: Request): Promise<null> => {
61
+ const { account } = getVtexConfig();
62
+ const cookieHeader = req.headers.get("cookie") ?? "";
63
+ const session = readCookie(cookieHeader, SESSION_COOKIE);
64
+ const anonymous = readCookie(cookieHeader, ANONYMOUS_COOKIE);
65
+
66
+ if (!session || !anonymous) {
67
+ throw new Error("Missing IS Cookies");
68
+ }
69
+
70
+ const url = `https://sp.vtex.com/event-api/v1/${account}/event`;
71
+ await fetch(url, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({
75
+ ...props,
76
+ session,
77
+ anonymous,
78
+ agent: req.headers.get("user-agent") || "deco-sites/apps",
79
+ }),
80
+ });
81
+
82
+ return null;
83
+ };
84
+
85
+ export default action;
@@ -1,36 +1,43 @@
1
1
  /**
2
2
  * Client-side cart hook for VTEX.
3
3
  *
4
- * Uses TanStack Query for SWR, optimistic updates, and cache
5
- * invalidation. Wraps the VTEX orderForm API.
4
+ * Uses TanStack Query for SWR, optimistic updates, and cache invalidation.
5
+ * Returns BOTH the raw `OrderForm` (back-compat for existing consumers) AND
6
+ * the canonical `Minicart` shape (preferred for new code).
6
7
  *
7
- * @example
8
+ * @example Reading the cart
8
9
  * ```tsx
9
10
  * import { useCart } from "@decocms/apps/vtex/hooks/useCart";
10
11
  *
11
12
  * function CartButton() {
12
- * const { cart, addItems, isLoading } = useCart();
13
- * const count = cart?.items?.length ?? 0;
13
+ * const { minicart, isLoading } = useCart({ freeShippingTarget: 200 });
14
+ * const count = minicart?.storefront.items.length ?? 0;
14
15
  * return <button disabled={isLoading}>{count} items</button>;
15
16
  * }
16
17
  * ```
18
+ *
19
+ * @example Mutations
20
+ * ```tsx
21
+ * const { addItems, removeItem, addCoupons } = useCart();
22
+ * addItems.mutate([{ id: "123", seller: "1", quantity: 1 }]);
23
+ * ```
17
24
  */
18
25
 
19
26
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27
+ import { useMemo } from "react";
28
+ import type { Minicart } from "../../commerce/types/commerce";
29
+ import type { OrderForm, OrderFormItem } from "../types";
30
+ import { vtexOrderFormToMinicart } from "../utils/minicart";
20
31
 
21
- export interface CartItem {
22
- id: string;
23
- quantity: number;
24
- seller: string;
25
- }
32
+ /** Re-exported from `vtex/types` for back-compat. New code should import directly. */
33
+ export type { OrderForm } from "../types";
26
34
 
27
- export interface OrderForm {
28
- orderFormId: string;
29
- items: CartItem[];
30
- totalizers: Array<{ id: string; name: string; value: number }>;
31
- value: number;
32
- [key: string]: unknown;
33
- }
35
+ /**
36
+ * Slim cart-item shape used by mutations.
37
+ * @deprecated Use `OrderFormItem` from `@decocms/apps/vtex/types` for full fidelity,
38
+ * or `MinicartItem` from `@decocms/apps/commerce/types` for the canonical contract.
39
+ */
40
+ export type CartItem = Pick<OrderFormItem, "id" | "quantity" | "seller">;
34
41
 
35
42
  const CART_QUERY_KEY = ["vtex", "cart"] as const;
36
43
 
@@ -136,6 +143,14 @@ export interface UseCartOptions {
136
143
  enabled?: boolean;
137
144
  /** Stale time in ms. @default 30000 */
138
145
  staleTime?: number;
146
+ /** Free-shipping threshold in major units, surfaced on `minicart.storefront`. @default 0 */
147
+ freeShippingTarget?: number;
148
+ /** Override the OrderForm's locale (BCP-47, e.g. `"pt-BR"`). */
149
+ locale?: string;
150
+ /** Where the checkout button sends the user. @default "/checkout" */
151
+ checkoutHref?: string;
152
+ /** Whether to surface the coupon input. @default true */
153
+ enableCoupon?: boolean;
139
154
  }
140
155
 
141
156
  export function useCart(options?: UseCartOptions) {
@@ -148,6 +163,24 @@ export function useCart(options?: UseCartOptions) {
148
163
  enabled: options?.enabled !== false,
149
164
  });
150
165
 
166
+ const cart = query.data ?? null;
167
+
168
+ const minicart: Minicart<OrderForm> | null = useMemo(() => {
169
+ if (!cart) return null;
170
+ return vtexOrderFormToMinicart(cart, {
171
+ freeShippingTarget: options?.freeShippingTarget,
172
+ locale: options?.locale,
173
+ checkoutHref: options?.checkoutHref,
174
+ enableCoupon: options?.enableCoupon,
175
+ });
176
+ }, [
177
+ cart,
178
+ options?.freeShippingTarget,
179
+ options?.locale,
180
+ options?.checkoutHref,
181
+ options?.enableCoupon,
182
+ ]);
183
+
151
184
  const addItems = useMutation({
152
185
  mutationFn: (items: Array<{ id: string; quantity: number; seller: string }>) => {
153
186
  const orderFormId = query.data?.orderFormId;
@@ -193,7 +226,10 @@ export function useCart(options?: UseCartOptions) {
193
226
  });
194
227
 
195
228
  return {
196
- cart: query.data ?? null,
229
+ /** Raw VTEX OrderForm — escape hatch for platform-specific reads. */
230
+ cart,
231
+ /** Canonical platform-agnostic minicart. Prefer this in new UI code. */
232
+ minicart,
197
233
  isLoading: query.isLoading,
198
234
  isError: query.isError,
199
235
  error: query.error,
@@ -202,6 +238,6 @@ export function useCart(options?: UseCartOptions) {
202
238
  addCoupons,
203
239
  updateQuantity,
204
240
  removeItem,
205
- itemCount: query.data?.items?.length ?? 0,
241
+ itemCount: cart?.items?.length ?? 0,
206
242
  };
207
243
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SSR loader returning the canonical `Minicart` for the current request.
3
+ *
4
+ * Reads `orderFormId` from the `checkout.vtex.com__orderFormId` cookie and
5
+ * fetches the corresponding OrderForm via `getOrCreateCart`. When no
6
+ * orderFormId cookie exists (first-time visitor, no items added yet), returns
7
+ * an empty `Minicart` shell — we deliberately avoid creating a new OrderForm
8
+ * server-side to prevent zero-item carts on every page view.
9
+ *
10
+ * Configurable via Props (free-shipping target, locale, checkout URL).
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * // setup/commerce-loaders.ts
15
+ * import minicart from "@decocms/apps/vtex/inline-loaders/minicart";
16
+ * registerInlineLoader("vtex/inline-loaders/minicart", minicart);
17
+ * ```
18
+ */
19
+
20
+ import { RequestContext } from "@decocms/start/sdk/requestContext";
21
+ import type { Minicart } from "../../commerce/types/commerce";
22
+ import { getOrCreateCart } from "../actions/checkout";
23
+ import type { OrderForm } from "../types";
24
+ import { vtexOrderFormToMinicart } from "../utils/minicart";
25
+
26
+ const ORDER_FORM_COOKIE = "checkout.vtex.com__orderFormId";
27
+
28
+ export interface MinicartProps {
29
+ /** Free-shipping threshold in major units. `0` disables the progress bar. */
30
+ freeShippingTarget?: number;
31
+ /** Override the OrderForm's locale (BCP-47, e.g. `"pt-BR"`). */
32
+ locale?: string;
33
+ /** Where the checkout button sends the user. Default: `/checkout`. */
34
+ checkoutHref?: string;
35
+ /** Whether the UI should expose the coupon input. Default: `true`. */
36
+ enableCoupon?: boolean;
37
+ }
38
+
39
+ function readOrderFormIdFromRequest(): string | undefined {
40
+ const ctx = RequestContext.current;
41
+ const cookieHeader = ctx?.request.headers.get("cookie");
42
+ if (!cookieHeader) return undefined;
43
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${ORDER_FORM_COOKIE}=([^;]+)`));
44
+ return match?.[1] ? decodeURIComponent(match[1]) : undefined;
45
+ }
46
+
47
+ /** Empty cart shell returned when no orderFormId is yet associated with the visitor. */
48
+ function emptyMinicart(opts: MinicartProps): Minicart<OrderForm | null> {
49
+ return {
50
+ original: null,
51
+ storefront: {
52
+ items: [],
53
+ subtotal: 0,
54
+ discounts: 0,
55
+ total: 0,
56
+ locale: opts.locale ?? "pt-BR",
57
+ currency: "BRL",
58
+ enableCoupon: opts.enableCoupon ?? true,
59
+ freeShippingTarget: opts.freeShippingTarget ?? 0,
60
+ checkoutHref: opts.checkoutHref ?? "/checkout",
61
+ },
62
+ };
63
+ }
64
+
65
+ export default async function vtexMinicart(
66
+ props: MinicartProps = {},
67
+ ): Promise<Minicart<OrderForm | null>> {
68
+ const orderFormId = readOrderFormIdFromRequest();
69
+ if (!orderFormId) return emptyMinicart(props);
70
+
71
+ const orderForm = await getOrCreateCart({ orderFormId });
72
+ return vtexOrderFormToMinicart(orderForm, {
73
+ freeShippingTarget: props.freeShippingTarget,
74
+ locale: props.locale,
75
+ checkoutHref: props.checkoutHref,
76
+ enableCoupon: props.enableCoupon,
77
+ });
78
+ }
@@ -20,28 +20,35 @@ export interface ProductListProps {
20
20
  hideUnavailableItems?: boolean;
21
21
  }
22
22
 
23
- interface CollectionProps {
23
+ /** @title Collection ID */
24
+ export interface CollectionProps {
25
+ /** VTEX product cluster id (e.g. `"150"`). */
24
26
  collection: string;
25
27
  count?: number;
26
28
  sort?: string;
27
29
  hideUnavailableItems?: boolean;
28
30
  }
29
31
 
30
- interface QueryProps {
32
+ /** @title Keyword Search */
33
+ export interface QueryProps {
31
34
  query: string;
32
35
  count?: number;
33
36
  sort?: string;
37
+ /** Pass either a raw IS fuzzy value (`"0" | "1" | "auto"`) or call `mapLabelledFuzzyToFuzzy()`. */
34
38
  fuzzy?: string;
35
39
  hideUnavailableItems?: boolean;
36
40
  }
37
41
 
38
- interface ProductIDProps {
42
+ /** @title Product IDs */
43
+ export interface ProductIDProps {
39
44
  ids: string[];
40
45
  hideUnavailableItems?: boolean;
41
46
  }
42
47
 
43
- interface FacetsProps {
48
+ /** @title Advanced Facets */
49
+ export interface FacetsProps {
44
50
  query?: string;
51
+ /** Facets path (e.g. `category-1/moda-feminina/category-2/calcados`). */
45
52
  facets: string;
46
53
  count?: number;
47
54
  sort?: string;
@@ -14,6 +14,33 @@ export interface SelectedFacet {
14
14
  value: string;
15
15
  }
16
16
 
17
+ /**
18
+ * Friendly fuzzy labels for CMS UIs. Translate to the raw IS API value via
19
+ * {@link mapLabelledFuzzyToFuzzy} before passing into a loader's `fuzzy` field.
20
+ */
21
+ export type LabelledFuzzy = "automatic" | "disabled" | "enabled";
22
+
23
+ /**
24
+ * Translate a friendly fuzzy label to the value the VTEX Intelligent Search
25
+ * API expects. Returns `undefined` when the label is omitted so callers can
26
+ * skip the param entirely.
27
+ *
28
+ * @example
29
+ * intelligentSearch({ fuzzy: mapLabelledFuzzyToFuzzy(props.fuzzy) })
30
+ */
31
+ export const mapLabelledFuzzyToFuzzy = (label?: LabelledFuzzy): "0" | "1" | "auto" | undefined => {
32
+ switch (label) {
33
+ case "automatic":
34
+ return "auto";
35
+ case "enabled":
36
+ return "1";
37
+ case "disabled":
38
+ return "0";
39
+ default:
40
+ return undefined;
41
+ }
42
+ };
43
+
17
44
  export interface PLPProps {
18
45
  query?: string;
19
46
  count?: number;
@@ -305,7 +305,7 @@ const getTerm = (path: string, map: string) => {
305
305
  return mapSegments.includes("priceFrom") ? formatPriceFromPathToFacet(term) : term;
306
306
  };
307
307
 
308
- const getFirstItemAvailable = (item: LegacyItem) =>
308
+ export const getFirstItemAvailable = (item: LegacyItem) =>
309
309
  !!item?.sellers?.find((s) => s.commertialOffer?.AvailableQuantity > 0);
310
310
 
311
311
  const getTermFallback = (url: URL, isPage: boolean, hasFilters: boolean) => {
@@ -2,6 +2,7 @@
2
2
  // This file is checked into source control and updated via: npm run generate:manifests
3
3
 
4
4
  import * as actions_address from "./actions/address";
5
+ import * as actions_analytics_sendEvent from "./actions/analytics/sendEvent";
5
6
  import * as actions_auth from "./actions/auth";
6
7
  import * as actions_checkout from "./actions/checkout";
7
8
  import * as actions_masterData from "./actions/masterData";
@@ -59,6 +60,7 @@ const manifest = {
59
60
  },
60
61
  actions: {
61
62
  "vtex/actions/address": actions_address,
63
+ "vtex/actions/analytics/sendEvent": actions_analytics_sendEvent,
62
64
  "vtex/actions/auth": actions_auth,
63
65
  "vtex/actions/checkout": actions_checkout,
64
66
  "vtex/actions/masterData": actions_masterData,
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Generic VTEX fetch helpers.
3
+ *
4
+ * These are the canonical primitives for ad-hoc VTEX API calls in apps-start
5
+ * (custom path-resolution loaders, sitemap loaders, custom analytics, etc.).
6
+ * For typed catalog/IS/checkout calls prefer the {@link import("../client").vtexFetch}
7
+ * client which is wired into the configured account.
8
+ *
9
+ * Provides:
10
+ * - {@link fetchSafe} — fetch wrapper that throws {@link HttpError} on non-2xx
11
+ * - {@link fetchAPI} — same, parsed to JSON
12
+ *
13
+ * URLs are sanitized for known XSS-prone query params (utm_*, ft, map) before
14
+ * dispatch. This mirrors the security posture VTEX storefronts carry across
15
+ * the platform.
16
+ */
17
+
18
+ type CachingMode = "stale-while-revalidate";
19
+
20
+ type DecoInit = {
21
+ cache: CachingMode;
22
+ cacheTtlByStatus?: Array<{ from: number; to: number; ttl: number }>;
23
+ };
24
+
25
+ export type DecoRequestInit = RequestInit & { deco?: DecoInit };
26
+
27
+ export class HttpError extends Error {
28
+ readonly status: number;
29
+ readonly response: Response;
30
+
31
+ constructor(response: Response) {
32
+ super(`HTTP ${response.status} ${response.statusText} — ${response.url}`);
33
+ this.name = "HttpError";
34
+ this.status = response.status;
35
+ this.response = response;
36
+ }
37
+ }
38
+
39
+ const removeNonLatin1Chars = (str: string): string =>
40
+ // eslint-disable-next-line no-control-regex
41
+ str.replace(/[^\x00-\xFF]/g, "");
42
+
43
+ const removeScriptChars = (str: string): string => str.replace(/[<>]/g, "");
44
+
45
+ const QS_TO_REMOVE_PLUS = ["utm_campaign", "utm_medium", "utm_source", "map"];
46
+ const QS_TO_REPLACE_PLUS = ["ft"];
47
+
48
+ const sanitizeUrl = (input: string | URL | Request): string | Request | URL => {
49
+ let url: URL;
50
+
51
+ if (typeof input === "string") {
52
+ try {
53
+ url = new URL(input);
54
+ } catch {
55
+ return input;
56
+ }
57
+ } else if (input instanceof URL) {
58
+ url = input;
59
+ } else {
60
+ return input;
61
+ }
62
+
63
+ for (const key of QS_TO_REMOVE_PLUS) {
64
+ if (!url.searchParams.has(key)) continue;
65
+ const values = url.searchParams.getAll(key);
66
+ const cleaned = values.map((v) => removeScriptChars(removeNonLatin1Chars(v))).filter(Boolean);
67
+ url.searchParams.delete(key);
68
+ for (const v of cleaned) url.searchParams.append(key, v);
69
+ }
70
+
71
+ for (const key of QS_TO_REPLACE_PLUS) {
72
+ if (!url.searchParams.has(key)) continue;
73
+ const values = url.searchParams.getAll(key);
74
+ const cleaned = values.map((v) => encodeURIComponent(v.trim()));
75
+ url.searchParams.delete(key);
76
+ for (const v of cleaned) url.searchParams.append(key, v);
77
+ }
78
+
79
+ return url.toString();
80
+ };
81
+
82
+ /**
83
+ * Fetch wrapper that throws {@link HttpError} on non-2xx responses and
84
+ * sanitizes URL query strings.
85
+ */
86
+ export async function fetchSafe(
87
+ input: string | URL | Request,
88
+ init?: DecoRequestInit,
89
+ ): Promise<Response> {
90
+ const sanitized = sanitizeUrl(input);
91
+ const response = await fetch(sanitized as RequestInfo, init);
92
+ if (!response.ok) {
93
+ throw new HttpError(response);
94
+ }
95
+ return response;
96
+ }
97
+
98
+ /**
99
+ * Fetch wrapper that parses the response as JSON. Throws on non-2xx.
100
+ */
101
+ export async function fetchAPI<T = unknown>(
102
+ input: string | URL | Request,
103
+ init?: DecoRequestInit,
104
+ ): Promise<T> {
105
+ const response = await fetchSafe(input, init);
106
+ return response.json() as Promise<T>;
107
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Map a VTEX OrderForm to the canonical `Minicart` contract.
3
+ *
4
+ * Pure function — no I/O, fully unit-testable. Pricing is converted from
5
+ * VTEX's native cents to major units (the canonical unit for `Minicart`).
6
+ *
7
+ * Locale and currency come from `orderForm.storePreferencesData` and follow
8
+ * VTEX's `storePreferencesData.countryCode` / `currencyCode` semantics.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { vtexOrderFormToMinicart } from "@decocms/apps/vtex/utils/minicart";
13
+ * import { getCart } from "@decocms/apps/vtex/loaders/cart";
14
+ *
15
+ * const orderForm = await getCart(orderFormId);
16
+ * const minicart = vtexOrderFormToMinicart(orderForm, {
17
+ * freeShippingTarget: 0,
18
+ * checkoutHref: "/checkout",
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import type { Minicart, MinicartItem } from "../../commerce/types/commerce";
24
+ import type { OrderForm, OrderFormItem, Totalizer } from "../types";
25
+
26
+ export interface VtexOrderFormToMinicartOptions {
27
+ /** Free-shipping threshold in major units. `0` disables the progress bar. */
28
+ freeShippingTarget?: number;
29
+ /** Override the OrderForm's `clientPreferencesData.locale` (BCP-47, e.g. `"pt-BR"`). */
30
+ locale?: string;
31
+ /** Where the checkout button sends the user. Default: `/checkout`. */
32
+ checkoutHref?: string;
33
+ /** Whether the UI should expose the coupon input. Default: `true`. */
34
+ enableCoupon?: boolean;
35
+ }
36
+
37
+ const CENTS_PER_MAJOR = 100;
38
+
39
+ /** Convert VTEX cents to major units. Always returns a finite number. */
40
+ function fromCents(cents: number | undefined | null): number {
41
+ if (cents == null || !Number.isFinite(cents)) return 0;
42
+ return cents / CENTS_PER_MAJOR;
43
+ }
44
+
45
+ function findTotalizer(totalizers: Totalizer[] | undefined, id: string): number {
46
+ if (!totalizers) return 0;
47
+ const t = totalizers.find((x) => x.id === id);
48
+ return t?.value ?? 0;
49
+ }
50
+
51
+ /**
52
+ * Locale heuristic. VTEX exposes `clientPreferencesData.locale` when set, but
53
+ * otherwise we synthesize one from `storePreferencesData.countryCode` so the UI
54
+ * always has a usable value for `Intl.NumberFormat`.
55
+ */
56
+ function inferLocale(orderForm: OrderForm, override?: string): string {
57
+ if (override) return override;
58
+ const explicit = orderForm.clientPreferencesData?.locale;
59
+ if (explicit) return explicit;
60
+
61
+ const country = orderForm.storePreferencesData?.countryCode;
62
+ if (country === "BRA" || country === "BR") return "pt-BR";
63
+ if (country === "USA" || country === "US") return "en-US";
64
+ return "en-US";
65
+ }
66
+
67
+ function vtexItemToMinicartItem(item: OrderFormItem, index: number, coupon?: string): MinicartItem {
68
+ const sellingPrice = fromCents(item.sellingPrice ?? item.price);
69
+ const listPrice = fromCents(item.listPrice ?? item.price);
70
+ const discount = Math.max(0, listPrice - sellingPrice);
71
+
72
+ return {
73
+ // AnalyticsItem identifier — VTEX uses productId; sites map to numeric SKU
74
+ // when needed via `Number(item.item_id)` (see bagaggio Minicart).
75
+ item_id: item.id,
76
+ item_group_id: item.productId,
77
+ item_name: item.name ?? item.skuName ?? "",
78
+ item_variant: item.skuName,
79
+ item_brand: item.additionalInfo?.brandName ?? undefined,
80
+ item_url: item.detailUrl,
81
+ coupon,
82
+ affiliation: item.seller,
83
+ index,
84
+ // Cart-required fields
85
+ image: item.imageUrl?.replace(/^http:/, "https:") ?? "",
86
+ listPrice,
87
+ price: sellingPrice,
88
+ quantity: item.quantity,
89
+ discount: Number(discount.toFixed(2)),
90
+ // Platform-specific
91
+ seller: item.seller,
92
+ attachments: item.attachments as MinicartItem["attachments"],
93
+ attachmentOfferings: item.attachmentOfferings as MinicartItem["attachmentOfferings"],
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Map a VTEX `OrderForm` to the canonical platform-agnostic `Minicart`.
99
+ *
100
+ * @param orderForm - Result from `getCart()` or `getOrCreateCart()`.
101
+ * @param opts - Storefront-level overrides (free-shipping target, checkout href, ...).
102
+ */
103
+ export function vtexOrderFormToMinicart(
104
+ orderForm: OrderForm,
105
+ opts: VtexOrderFormToMinicartOptions = {},
106
+ ): Minicart<OrderForm> {
107
+ const totalizers = orderForm.totalizers;
108
+ const subtotal = fromCents(findTotalizer(totalizers, "Items"));
109
+ const discountsRaw = findTotalizer(totalizers, "Discounts");
110
+ const discounts = Math.abs(fromCents(discountsRaw));
111
+ const shippingRaw = findTotalizer(totalizers, "Shipping");
112
+ const shipping = totalizers?.some((t) => t.id === "Shipping")
113
+ ? fromCents(shippingRaw)
114
+ : undefined;
115
+ const total = fromCents(orderForm.value);
116
+
117
+ const coupon = orderForm.marketingData?.coupon;
118
+ const items = (orderForm.items ?? []).map((item, index) =>
119
+ vtexItemToMinicartItem(item, index, coupon),
120
+ );
121
+
122
+ return {
123
+ original: orderForm,
124
+ storefront: {
125
+ items,
126
+ subtotal,
127
+ discounts,
128
+ shipping,
129
+ total,
130
+ coupon,
131
+ locale: inferLocale(orderForm, opts.locale),
132
+ currency: orderForm.storePreferencesData?.currencyCode ?? "BRL",
133
+ enableCoupon: opts.enableCoupon ?? true,
134
+ freeShippingTarget: opts.freeShippingTarget ?? 0,
135
+ checkoutHref: opts.checkoutHref ?? "/checkout",
136
+ postalCode: orderForm.shippingData?.address?.postalCode ?? undefined,
137
+ },
138
+ };
139
+ }