@decocms/apps 1.4.0 → 1.5.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/commerce/types/commerce.ts +130 -0
- package/package.json +3 -1
- package/registry.ts +59 -0
- package/vtex/hooks/useCart.ts +55 -19
- package/vtex/inline-loaders/minicart.ts +80 -0
- package/vtex/utils/minicart.ts +137 -0
|
@@ -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.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
|
|
6
6
|
"exports": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"./vtex/inline-loaders/productListShelf": "./vtex/inline-loaders/productListShelf.ts",
|
|
41
41
|
"./vtex/inline-loaders/relatedProducts": "./vtex/inline-loaders/relatedProducts.ts",
|
|
42
42
|
"./vtex/inline-loaders/suggestions": "./vtex/inline-loaders/suggestions.ts",
|
|
43
|
+
"./vtex/inline-loaders/minicart": "./vtex/inline-loaders/minicart.ts",
|
|
43
44
|
"./vtex/hooks": "./vtex/hooks/index.ts",
|
|
44
45
|
"./vtex/hooks/*": "./vtex/hooks/*.ts",
|
|
45
46
|
"./vtex/inline-loaders/workflowProducts": "./vtex/inline-loaders/workflowProducts.ts",
|
|
@@ -95,6 +96,7 @@
|
|
|
95
96
|
"vtex/",
|
|
96
97
|
"resend/",
|
|
97
98
|
"website/",
|
|
99
|
+
"registry.ts",
|
|
98
100
|
"!**/__tests__/",
|
|
99
101
|
"!scripts/"
|
|
100
102
|
],
|
package/registry.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative catalogue of installable apps published under `@decocms/apps`.
|
|
3
|
+
*
|
|
4
|
+
* `@decocms/start`'s `autoconfigApps()` consumes this array to wire CMS
|
|
5
|
+
* commerce loaders and admin invoke handlers for whichever apps the host
|
|
6
|
+
* site has configured in its decofile. New apps are added here — no edit
|
|
7
|
+
* to the framework or the site is required.
|
|
8
|
+
*
|
|
9
|
+
* Import path: `@decocms/apps/registry`
|
|
10
|
+
*
|
|
11
|
+
* NOTE: the type is inlined rather than imported from
|
|
12
|
+
* `@decocms/start/apps` so this file ships in `@decocms/apps@1.4.0`
|
|
13
|
+
* against any installed `@decocms/start` version. Once callers pin a
|
|
14
|
+
* start version that exposes `AppRegistry`, the type can be swapped.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface AppRegistryEntry {
|
|
18
|
+
/** Block key in the decofile, e.g. "deco-shopify". */
|
|
19
|
+
blockKey: string;
|
|
20
|
+
/** Lazy dynamic import of the app's mod module. */
|
|
21
|
+
module: () => Promise<any>;
|
|
22
|
+
/** Human-readable name shown in admin install UI. */
|
|
23
|
+
displayName?: string;
|
|
24
|
+
/** Icon URL (absolute or site-relative) shown in admin install UI. */
|
|
25
|
+
icon?: string;
|
|
26
|
+
/** Grouping label, e.g. "commerce", "email", "analytics". */
|
|
27
|
+
category?: string;
|
|
28
|
+
/** Short summary shown in admin install UI. */
|
|
29
|
+
description?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type AppRegistry = readonly AppRegistryEntry[];
|
|
33
|
+
|
|
34
|
+
export const APP_REGISTRY: AppRegistry = [
|
|
35
|
+
{
|
|
36
|
+
blockKey: "deco-shopify",
|
|
37
|
+
module: () => import("./shopify/mod"),
|
|
38
|
+
displayName: "Shopify",
|
|
39
|
+
category: "commerce",
|
|
40
|
+
description: "Shopify Storefront API commerce integration",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
blockKey: "deco-vtex",
|
|
44
|
+
module: () => import("./vtex/mod"),
|
|
45
|
+
displayName: "VTEX",
|
|
46
|
+
category: "commerce",
|
|
47
|
+
description: "VTEX IO commerce integration",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
blockKey: "deco-resend",
|
|
51
|
+
module: () => import("./resend/mod"),
|
|
52
|
+
displayName: "Resend",
|
|
53
|
+
category: "email",
|
|
54
|
+
description: "Transactional email via Resend",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export default APP_REGISTRY;
|
|
59
|
+
export type { AppRegistryEntry, AppRegistry };
|
package/vtex/hooks/useCart.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
13
|
-
* const count =
|
|
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 { vtexOrderFormToMinicart } from "../utils/minicart";
|
|
30
|
+
import type { OrderForm, OrderFormItem } from "../types";
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
241
|
+
itemCount: cart?.items?.length ?? 0,
|
|
206
242
|
};
|
|
207
243
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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(
|
|
44
|
+
new RegExp(`(?:^|;\\s*)${ORDER_FORM_COOKIE}=([^;]+)`),
|
|
45
|
+
);
|
|
46
|
+
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Empty cart shell returned when no orderFormId is yet associated with the visitor. */
|
|
50
|
+
function emptyMinicart(opts: MinicartProps): Minicart<OrderForm | null> {
|
|
51
|
+
return {
|
|
52
|
+
original: null,
|
|
53
|
+
storefront: {
|
|
54
|
+
items: [],
|
|
55
|
+
subtotal: 0,
|
|
56
|
+
discounts: 0,
|
|
57
|
+
total: 0,
|
|
58
|
+
locale: opts.locale ?? "pt-BR",
|
|
59
|
+
currency: "BRL",
|
|
60
|
+
enableCoupon: opts.enableCoupon ?? true,
|
|
61
|
+
freeShippingTarget: opts.freeShippingTarget ?? 0,
|
|
62
|
+
checkoutHref: opts.checkoutHref ?? "/checkout",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default async function vtexMinicart(
|
|
68
|
+
props: MinicartProps = {},
|
|
69
|
+
): Promise<Minicart<OrderForm | null>> {
|
|
70
|
+
const orderFormId = readOrderFormIdFromRequest();
|
|
71
|
+
if (!orderFormId) return emptyMinicart(props);
|
|
72
|
+
|
|
73
|
+
const orderForm = await getOrCreateCart({ orderFormId });
|
|
74
|
+
return vtexOrderFormToMinicart(orderForm, {
|
|
75
|
+
freeShippingTarget: props.freeShippingTarget,
|
|
76
|
+
locale: props.locale,
|
|
77
|
+
checkoutHref: props.checkoutHref,
|
|
78
|
+
enableCoupon: props.enableCoupon,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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") ? fromCents(shippingRaw) : undefined;
|
|
113
|
+
const total = fromCents(orderForm.value);
|
|
114
|
+
|
|
115
|
+
const coupon = orderForm.marketingData?.coupon;
|
|
116
|
+
const items = (orderForm.items ?? []).map((item, index) =>
|
|
117
|
+
vtexItemToMinicartItem(item, index, coupon),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
original: orderForm,
|
|
122
|
+
storefront: {
|
|
123
|
+
items,
|
|
124
|
+
subtotal,
|
|
125
|
+
discounts,
|
|
126
|
+
shipping,
|
|
127
|
+
total,
|
|
128
|
+
coupon,
|
|
129
|
+
locale: inferLocale(orderForm, opts.locale),
|
|
130
|
+
currency: orderForm.storePreferencesData?.currencyCode ?? "BRL",
|
|
131
|
+
enableCoupon: opts.enableCoupon ?? true,
|
|
132
|
+
freeShippingTarget: opts.freeShippingTarget ?? 0,
|
|
133
|
+
checkoutHref: opts.checkoutHref ?? "/checkout",
|
|
134
|
+
postalCode: orderForm.shippingData?.address?.postalCode ?? undefined,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|