@decocms/apps-vtex 7.24.0 → 7.25.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 +12 -5
- package/src/actions/checkout.ts +475 -338
- package/src/hooks/__tests__/createCart.render.test.ts +114 -0
- package/src/hooks/__tests__/createCart.test.ts +92 -0
- package/src/hooks/cartQuery.ts +204 -0
- package/src/hooks/createCart.ts +427 -0
- package/src/hooks/index.ts +6 -0
- package/src/invoke.ts +159 -95
- package/src/loaders/cart/attachments.ts +53 -0
- package/src/loaders/cart/full.ts +67 -0
- package/src/loaders/cart/gifts.ts +43 -0
- package/src/loaders/cart/orderFormId.ts +31 -0
- package/src/loaders/cart/shipping.test.ts +38 -1
- package/src/loaders/cart/shipping.ts +67 -1
- package/src/loaders/cart/summary.ts +36 -0
- package/src/manifest.gen.ts +48 -38
- package/src/utils/__tests__/cartProjection.test.ts +118 -0
- package/src/utils/cartProjection.ts +96 -0
package/src/invoke.ts
CHANGED
|
@@ -24,23 +24,30 @@
|
|
|
24
24
|
* 1. Add an entry below with the input/output types and the action call,
|
|
25
25
|
* 2. From a site repo: `npm run generate:invoke`.
|
|
26
26
|
*/
|
|
27
|
+
|
|
28
|
+
import type { CartProjection, CartSection } from "@decocms/apps-commerce/types";
|
|
27
29
|
import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke";
|
|
28
30
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
addCouponToCart,
|
|
32
|
+
addCouponToCartV2,
|
|
33
|
+
addItemsToCart,
|
|
34
|
+
addItemsToCartV2,
|
|
35
|
+
getOrCreateCart,
|
|
36
|
+
getOrCreateCartV2,
|
|
37
|
+
getSellersByRegion,
|
|
38
|
+
type RegionResult,
|
|
39
|
+
type SimulationItem,
|
|
40
|
+
setShippingPostalCode,
|
|
41
|
+
simulateCart,
|
|
42
|
+
updateCartItems,
|
|
43
|
+
updateCartItemsV2,
|
|
44
|
+
updateOrderFormAttachment,
|
|
39
45
|
} from "./actions/checkout";
|
|
40
46
|
import { type NotifyMeProps, notifyMe } from "./actions/misc";
|
|
41
47
|
import { type SubscribeProps, subscribe } from "./actions/newsletter";
|
|
42
48
|
import { createSession, editSession, type SessionData } from "./actions/session";
|
|
43
49
|
import type { OrderForm } from "./types";
|
|
50
|
+
import type { VtexCartProjectionResult } from "./utils/cartProjection";
|
|
44
51
|
|
|
45
52
|
// ---------------------------------------------------------------------------
|
|
46
53
|
// invoke.vtex.actions — typed server functions callable from client
|
|
@@ -53,81 +60,138 @@ import type { OrderForm } from "./types";
|
|
|
53
60
|
// ---------------------------------------------------------------------------
|
|
54
61
|
|
|
55
62
|
export const invoke = {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
63
|
+
vtex: {
|
|
64
|
+
actions: {
|
|
65
|
+
// -- Cart (OrderForm CRUD) --------------------------------------------
|
|
66
|
+
|
|
67
|
+
getOrCreateCart: createInvokeFn((data: { orderFormId?: string }) =>
|
|
68
|
+
getOrCreateCart(data),
|
|
69
|
+
) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
|
|
70
|
+
|
|
71
|
+
addItemsToCart: createInvokeFn(
|
|
72
|
+
(data: {
|
|
73
|
+
orderFormId: string;
|
|
74
|
+
orderItems: Array<{
|
|
75
|
+
id: string;
|
|
76
|
+
seller: string;
|
|
77
|
+
quantity: number;
|
|
78
|
+
}>;
|
|
79
|
+
}) => addItemsToCart(data),
|
|
80
|
+
) as unknown as (ctx: {
|
|
81
|
+
data: {
|
|
82
|
+
orderFormId: string;
|
|
83
|
+
orderItems: Array<{
|
|
84
|
+
id: string;
|
|
85
|
+
seller: string;
|
|
86
|
+
quantity: number;
|
|
87
|
+
}>;
|
|
88
|
+
};
|
|
89
|
+
}) => Promise<OrderForm>,
|
|
90
|
+
|
|
91
|
+
updateCartItems: createInvokeFn(
|
|
92
|
+
(data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }) =>
|
|
93
|
+
updateCartItems(data),
|
|
94
|
+
) as unknown as (ctx: {
|
|
95
|
+
data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> };
|
|
96
|
+
}) => Promise<OrderForm>,
|
|
97
|
+
|
|
98
|
+
addCouponToCart: createInvokeFn((data: { orderFormId: string; text: string }) =>
|
|
99
|
+
addCouponToCart(data),
|
|
100
|
+
) as unknown as (ctx: { data: { orderFormId: string; text: string } }) => Promise<OrderForm>,
|
|
101
|
+
|
|
102
|
+
// -- Cart v2 (fragmented sections + projected response) ----------------
|
|
103
|
+
|
|
104
|
+
getOrCreateCartV2: createInvokeFn(
|
|
105
|
+
(data: { orderFormId?: string; projection?: CartProjection; sections?: CartSection[] }) =>
|
|
106
|
+
getOrCreateCartV2(data),
|
|
107
|
+
) as unknown as (ctx: {
|
|
108
|
+
data: { orderFormId?: string; projection?: CartProjection; sections?: CartSection[] };
|
|
109
|
+
}) => Promise<VtexCartProjectionResult>,
|
|
110
|
+
|
|
111
|
+
addItemsToCartV2: createInvokeFn(
|
|
112
|
+
(data: {
|
|
113
|
+
orderFormId: string;
|
|
114
|
+
orderItems: Array<{ id: string; seller: string; quantity: number }>;
|
|
115
|
+
projection?: CartProjection;
|
|
116
|
+
sections?: CartSection[];
|
|
117
|
+
}) => addItemsToCartV2(data),
|
|
118
|
+
) as unknown as (ctx: {
|
|
119
|
+
data: {
|
|
120
|
+
orderFormId: string;
|
|
121
|
+
orderItems: Array<{ id: string; seller: string; quantity: number }>;
|
|
122
|
+
projection?: CartProjection;
|
|
123
|
+
sections?: CartSection[];
|
|
124
|
+
};
|
|
125
|
+
}) => Promise<VtexCartProjectionResult>,
|
|
126
|
+
|
|
127
|
+
updateCartItemsV2: createInvokeFn(
|
|
128
|
+
(data: {
|
|
129
|
+
orderFormId: string;
|
|
130
|
+
orderItems: Array<{ index: number; quantity: number }>;
|
|
131
|
+
projection?: CartProjection;
|
|
132
|
+
sections?: CartSection[];
|
|
133
|
+
}) => updateCartItemsV2(data),
|
|
134
|
+
) as unknown as (ctx: {
|
|
135
|
+
data: {
|
|
136
|
+
orderFormId: string;
|
|
137
|
+
orderItems: Array<{ index: number; quantity: number }>;
|
|
138
|
+
projection?: CartProjection;
|
|
139
|
+
sections?: CartSection[];
|
|
140
|
+
};
|
|
141
|
+
}) => Promise<VtexCartProjectionResult>,
|
|
142
|
+
|
|
143
|
+
addCouponToCartV2: createInvokeFn(
|
|
144
|
+
(data: {
|
|
145
|
+
orderFormId: string;
|
|
146
|
+
text: string;
|
|
147
|
+
projection?: CartProjection;
|
|
148
|
+
sections?: CartSection[];
|
|
149
|
+
}) => addCouponToCartV2(data),
|
|
150
|
+
) as unknown as (ctx: {
|
|
151
|
+
data: {
|
|
152
|
+
orderFormId: string;
|
|
153
|
+
text: string;
|
|
154
|
+
projection?: CartProjection;
|
|
155
|
+
sections?: CartSection[];
|
|
156
|
+
};
|
|
157
|
+
}) => Promise<VtexCartProjectionResult>,
|
|
158
|
+
|
|
159
|
+
simulateCart: createInvokeFn(
|
|
160
|
+
(data: { items: SimulationItem[]; postalCode: string; country?: string }) =>
|
|
161
|
+
simulateCart(data),
|
|
162
|
+
),
|
|
163
|
+
|
|
164
|
+
// -- Shipping / Region ------------------------------------------------
|
|
165
|
+
|
|
166
|
+
getSellersByRegion: createInvokeFn((data: { postalCode: string; salesChannel?: string }) =>
|
|
167
|
+
getSellersByRegion(data),
|
|
168
|
+
) as unknown as (ctx: {
|
|
169
|
+
data: { postalCode: string; salesChannel?: string };
|
|
170
|
+
}) => Promise<RegionResult | null>,
|
|
171
|
+
|
|
172
|
+
setShippingPostalCode: createInvokeFn(
|
|
173
|
+
(data: { orderFormId: string; postalCode: string; country?: string }) =>
|
|
174
|
+
setShippingPostalCode(data),
|
|
175
|
+
) as unknown as (ctx: {
|
|
176
|
+
data: { orderFormId: string; postalCode: string; country?: string };
|
|
177
|
+
}) => Promise<boolean>,
|
|
178
|
+
|
|
179
|
+
updateOrderFormAttachment: createInvokeFn(
|
|
180
|
+
(data: { orderFormId: string; attachment: string; body: Record<string, unknown> }) =>
|
|
181
|
+
updateOrderFormAttachment(data),
|
|
182
|
+
) as unknown as (ctx: {
|
|
183
|
+
data: { orderFormId: string; attachment: string; body: Record<string, unknown> };
|
|
184
|
+
}) => Promise<OrderForm>,
|
|
185
|
+
|
|
186
|
+
// -- Session ----------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
createSession: createInvokeFn((data: Record<string, any>) => createSession({ data })),
|
|
189
|
+
|
|
190
|
+
editSession: createInvokeFn((data: { public: Record<string, { value: string }> }) =>
|
|
191
|
+
editSession(data),
|
|
192
|
+
) as unknown as (ctx: {
|
|
193
|
+
data: { public: Record<string, { value: string }> };
|
|
194
|
+
}) => Promise<SessionData>,
|
|
131
195
|
|
|
132
196
|
// -- MasterData -------------------------------------------------------
|
|
133
197
|
//
|
|
@@ -155,17 +219,17 @@ export const invoke = {
|
|
|
155
219
|
// generate-invoke.ts also carries a PRIVILEGED_ACTIONS guard that refuses
|
|
156
220
|
// to emit these names even if re-added here, so this stays enforced.
|
|
157
221
|
|
|
158
|
-
|
|
222
|
+
// -- Newsletter -------------------------------------------------------
|
|
159
223
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
224
|
+
subscribe: createInvokeFn((data: SubscribeProps) => subscribe(data)) as unknown as (ctx: {
|
|
225
|
+
data: SubscribeProps;
|
|
226
|
+
}) => Promise<void>,
|
|
163
227
|
|
|
164
|
-
|
|
228
|
+
// -- Misc -------------------------------------------------------------
|
|
165
229
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
230
|
+
notifyMe: createInvokeFn((data: NotifyMeProps) => notifyMe(data)) as unknown as (ctx: {
|
|
231
|
+
data: NotifyMeProps;
|
|
232
|
+
}) => Promise<void>,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
171
235
|
} as const;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vtex/loaders/cart/attachments` — one item's attachments + offered slots.
|
|
3
|
+
*
|
|
4
|
+
* For a customization UI (engraving, gift wrap, ...) that only needs a single
|
|
5
|
+
* line's attachment state. Fetches just `items` and returns that item's
|
|
6
|
+
* `attachments` (applied) and `attachmentOfferings` (available slots).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getOrCreateCartV2 } from "../../actions/checkout";
|
|
10
|
+
import type { OrderForm, OrderFormItem } from "../../types";
|
|
11
|
+
import { resolveOrderFormId } from "./orderFormId";
|
|
12
|
+
|
|
13
|
+
export interface CartItemAttachmentsProps {
|
|
14
|
+
orderFormId?: string;
|
|
15
|
+
/** Index of the line whose attachments to read. */
|
|
16
|
+
itemIndex: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CartItemAttachments {
|
|
20
|
+
orderFormId: string | null;
|
|
21
|
+
itemIndex: number;
|
|
22
|
+
attachments: NonNullable<OrderFormItem["attachments"]>;
|
|
23
|
+
attachmentOfferings: NonNullable<OrderFormItem["attachmentOfferings"]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default async function cartItemAttachments(
|
|
27
|
+
props: CartItemAttachmentsProps,
|
|
28
|
+
): Promise<CartItemAttachments> {
|
|
29
|
+
const orderFormId = resolveOrderFormId(props.orderFormId);
|
|
30
|
+
const empty: CartItemAttachments = {
|
|
31
|
+
orderFormId: null,
|
|
32
|
+
itemIndex: props.itemIndex,
|
|
33
|
+
attachments: [],
|
|
34
|
+
attachmentOfferings: [],
|
|
35
|
+
};
|
|
36
|
+
if (!orderFormId) return empty;
|
|
37
|
+
|
|
38
|
+
const orderForm = (await getOrCreateCartV2({
|
|
39
|
+
orderFormId,
|
|
40
|
+
projection: "raw",
|
|
41
|
+
sections: ["items"],
|
|
42
|
+
})) as OrderForm;
|
|
43
|
+
|
|
44
|
+
const item = orderForm.items?.[props.itemIndex];
|
|
45
|
+
if (!item) return { ...empty, orderFormId: orderForm.orderFormId ?? null };
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
orderFormId: orderForm.orderFormId ?? null,
|
|
49
|
+
itemIndex: props.itemIndex,
|
|
50
|
+
attachments: item.attachments ?? [],
|
|
51
|
+
attachmentOfferings: item.attachmentOfferings ?? [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vtex/loaders/cart/full` — the drawer/minicart loader.
|
|
3
|
+
*
|
|
4
|
+
* Returns the full canonical `Minicart`, but requests only `SECTIONS_DRAWER`
|
|
5
|
+
* from VTEX (not the legacy 15-section superset). Call it when the user opens
|
|
6
|
+
* the cart drawer — not on every page view.
|
|
7
|
+
*
|
|
8
|
+
* Like the summary loader, a first-time visitor gets an empty `Minicart` shell
|
|
9
|
+
* with no VTEX call.
|
|
10
|
+
*
|
|
11
|
+
* Registered in the app manifest as `vtex/loaders/cart/full`
|
|
12
|
+
* (`invoke.vtex.loaders.cart.full`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Minicart } from "@decocms/apps-commerce/types";
|
|
16
|
+
import { SECTIONS_DRAWER } from "@decocms/apps-commerce/types";
|
|
17
|
+
import { getOrCreateCartV2 } from "../../actions/checkout";
|
|
18
|
+
import type { OrderForm } from "../../types";
|
|
19
|
+
import { resolveOrderFormId } from "./orderFormId";
|
|
20
|
+
|
|
21
|
+
export interface CartFullProps {
|
|
22
|
+
orderFormId?: string;
|
|
23
|
+
/** Free-shipping threshold in major units. `0` disables the progress bar. */
|
|
24
|
+
freeShippingTarget?: number;
|
|
25
|
+
/** Override the OrderForm's locale (BCP-47, e.g. `"pt-BR"`). */
|
|
26
|
+
locale?: string;
|
|
27
|
+
/** Where the checkout button sends the user. Default: `/checkout`. */
|
|
28
|
+
checkoutHref?: string;
|
|
29
|
+
/** Whether the UI should expose the coupon input. Default: `true`. */
|
|
30
|
+
enableCoupon?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emptyMinicart(props: CartFullProps): Minicart<OrderForm | null> {
|
|
34
|
+
return {
|
|
35
|
+
original: null,
|
|
36
|
+
storefront: {
|
|
37
|
+
items: [],
|
|
38
|
+
subtotal: 0,
|
|
39
|
+
discounts: 0,
|
|
40
|
+
total: 0,
|
|
41
|
+
locale: props.locale ?? "pt-BR",
|
|
42
|
+
currency: "BRL",
|
|
43
|
+
enableCoupon: props.enableCoupon ?? true,
|
|
44
|
+
freeShippingTarget: props.freeShippingTarget ?? 0,
|
|
45
|
+
checkoutHref: props.checkoutHref ?? "/checkout",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default async function cartFull(
|
|
51
|
+
props: CartFullProps = {},
|
|
52
|
+
): Promise<Minicart<OrderForm | null>> {
|
|
53
|
+
const orderFormId = resolveOrderFormId(props.orderFormId);
|
|
54
|
+
if (!orderFormId) return emptyMinicart(props);
|
|
55
|
+
|
|
56
|
+
return (await getOrCreateCartV2({
|
|
57
|
+
orderFormId,
|
|
58
|
+
projection: "minicart",
|
|
59
|
+
sections: SECTIONS_DRAWER,
|
|
60
|
+
minicartOptions: {
|
|
61
|
+
freeShippingTarget: props.freeShippingTarget,
|
|
62
|
+
locale: props.locale,
|
|
63
|
+
checkoutHref: props.checkoutHref,
|
|
64
|
+
enableCoupon: props.enableCoupon,
|
|
65
|
+
},
|
|
66
|
+
})) as Minicart<OrderForm>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vtex/loaders/cart/gifts` — selectable gifts + promotion teasers only.
|
|
3
|
+
*
|
|
4
|
+
* A focused read: fetches just the `ratesAndBenefitsData` (+ `items` so gift
|
|
5
|
+
* eligibility resolves) and returns the gift-relevant slice, so a "you earned a
|
|
6
|
+
* gift" UI does not have to load the whole cart. Empty for visitors with no
|
|
7
|
+
* cart yet (no VTEX call).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getOrCreateCartV2 } from "../../actions/checkout";
|
|
11
|
+
import type { OrderForm } from "../../types";
|
|
12
|
+
import { resolveOrderFormId } from "./orderFormId";
|
|
13
|
+
|
|
14
|
+
export interface CartGiftsProps {
|
|
15
|
+
orderFormId?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CartGifts {
|
|
19
|
+
orderFormId: string | null;
|
|
20
|
+
/** VTEX `selectableGifts` — gifts the shopper can choose. */
|
|
21
|
+
selectableGifts: unknown[];
|
|
22
|
+
/** VTEX `ratesAndBenefitsData` — active promotions / teasers. */
|
|
23
|
+
ratesAndBenefits: OrderForm["ratesAndBenefitsData"] | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMPTY: CartGifts = { orderFormId: null, selectableGifts: [], ratesAndBenefits: null };
|
|
27
|
+
|
|
28
|
+
export default async function cartGifts(props: CartGiftsProps = {}): Promise<CartGifts> {
|
|
29
|
+
const orderFormId = resolveOrderFormId(props.orderFormId);
|
|
30
|
+
if (!orderFormId) return EMPTY;
|
|
31
|
+
|
|
32
|
+
const orderForm = (await getOrCreateCartV2({
|
|
33
|
+
orderFormId,
|
|
34
|
+
projection: "raw",
|
|
35
|
+
sections: ["items", "ratesAndBenefitsData", "messages"],
|
|
36
|
+
})) as OrderForm;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
orderFormId: orderForm.orderFormId ?? null,
|
|
40
|
+
selectableGifts: orderForm.selectableGifts ?? [],
|
|
41
|
+
ratesAndBenefits: orderForm.ratesAndBenefitsData ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the current visitor's `orderFormId` from the request cookie.
|
|
3
|
+
*
|
|
4
|
+
* Shared by every Cart v2 loader. Returns `undefined` for first-time visitors
|
|
5
|
+
* (no cart yet) — loaders MUST treat that as "empty, do not create" so a page
|
|
6
|
+
* view never provisions a zero-item OrderForm on VTEX.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
10
|
+
|
|
11
|
+
export const ORDER_FORM_COOKIE = "checkout.vtex.com__orderFormId";
|
|
12
|
+
|
|
13
|
+
/** Extract `orderFormId` from the inbound request cookie header, if present. */
|
|
14
|
+
export function readOrderFormIdFromRequest(cookieName = ORDER_FORM_COOKIE): string | undefined {
|
|
15
|
+
const ctx = RequestContext.current;
|
|
16
|
+
const cookieHeader = ctx?.request.headers.get("cookie");
|
|
17
|
+
if (!cookieHeader) return undefined;
|
|
18
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`));
|
|
19
|
+
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the orderFormId a loader should use: an explicit prop wins, else the
|
|
24
|
+
* cookie. `undefined` means "no cart exists yet".
|
|
25
|
+
*/
|
|
26
|
+
export function resolveOrderFormId(
|
|
27
|
+
explicit?: string,
|
|
28
|
+
cookieName = ORDER_FORM_COOKIE,
|
|
29
|
+
): string | undefined {
|
|
30
|
+
return explicit ?? readOrderFormIdFromRequest(cookieName);
|
|
31
|
+
}
|
|
@@ -10,7 +10,7 @@ vi.mock("../../actions/checkout", () => ({
|
|
|
10
10
|
simulateCart: (props: any) => simulateCartMock(props),
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
|
-
import { getShippingSimulation } from "./shipping";
|
|
13
|
+
import cartShipping, { getShippingSimulation } from "./shipping";
|
|
14
14
|
import { __resetSimulationCacheForTests } from "../../utils/simulationCache";
|
|
15
15
|
|
|
16
16
|
describe("getShippingSimulation (#373)", () => {
|
|
@@ -86,3 +86,40 @@ describe("getShippingSimulation (#373)", () => {
|
|
|
86
86
|
expect(JSON.stringify(result)).not.toContain("set-cookie");
|
|
87
87
|
});
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
describe("cartShipping (drawer loader, built on getShippingSimulation)", () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
configureVtex({ account: "acme", salesChannel: "1" });
|
|
93
|
+
__resetSimulationCacheForTests();
|
|
94
|
+
simulateCartMock.mockClear();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
__resetSimulationCacheForTests();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const props = {
|
|
102
|
+
items: [{ id: "1", quantity: 1, seller: "1" }],
|
|
103
|
+
postalCode: "01310-100",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("returns empty options when items or postalCode are missing", async () => {
|
|
107
|
+
expect(await cartShipping({ items: [], postalCode: "" })).toEqual({
|
|
108
|
+
postalCode: "",
|
|
109
|
+
options: [],
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("normalizes SLAs from the (cached) simulation into a de-duplicated option list", async () => {
|
|
114
|
+
const result = await cartShipping(props);
|
|
115
|
+
expect(result.postalCode).toBe(props.postalCode);
|
|
116
|
+
expect(result.options).toHaveLength(1);
|
|
117
|
+
expect(result.options[0].id).toBe("sla-1");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("shares the underlying simulation cache — a second call does not re-simulate", async () => {
|
|
121
|
+
await cartShipping(props);
|
|
122
|
+
await cartShipping(props);
|
|
123
|
+
expect(simulateCartMock).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -46,7 +46,7 @@ function buildCacheKey(props: ShippingSimulationProps): string {
|
|
|
46
46
|
* Fetch shipping SLAs for a cart, serving a cached response body when
|
|
47
47
|
* available for the same `{items, postalCode, salesChannel}` tuple.
|
|
48
48
|
*/
|
|
49
|
-
export async function getShippingSimulation(props: ShippingSimulationProps): Promise<
|
|
49
|
+
export async function getShippingSimulation(props: ShippingSimulationProps): Promise<any> {
|
|
50
50
|
const cache = getSimulationCache();
|
|
51
51
|
const key = buildCacheKey(props);
|
|
52
52
|
|
|
@@ -68,3 +68,69 @@ export async function getShippingSimulation(props: ShippingSimulationProps): Pro
|
|
|
68
68
|
await cache.put(key, JSON.stringify(result), props.ttlSeconds ?? DEFAULT_TTL_SECONDS);
|
|
69
69
|
return result;
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// `vtex/loaders/cart/shipping` — normalized shipping options for the drawer
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
//
|
|
76
|
+
// Runs a (cached) VTEX cart simulation for the given items + postal code and
|
|
77
|
+
// returns a normalized, de-duplicated list of shipping SLAs (price in major
|
|
78
|
+
// units). Separate from the cart mutation path so the drawer can show
|
|
79
|
+
// delivery estimates without re-fetching the whole OrderForm. Built on
|
|
80
|
+
// `getShippingSimulation` above instead of calling `simulateCart` directly,
|
|
81
|
+
// so repeated drawer opens for the same {items, postalCode} share the cache.
|
|
82
|
+
|
|
83
|
+
const CENTS_PER_MAJOR = 100;
|
|
84
|
+
|
|
85
|
+
export interface CartShippingProps {
|
|
86
|
+
items: SimulationItem[];
|
|
87
|
+
postalCode: string;
|
|
88
|
+
country?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ShippingOption {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
/** Price in major units. */
|
|
95
|
+
price: number;
|
|
96
|
+
shippingEstimate: string;
|
|
97
|
+
deliveryChannel?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CartShipping {
|
|
101
|
+
postalCode: string;
|
|
102
|
+
options: ShippingOption[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface SimSla {
|
|
106
|
+
id: string;
|
|
107
|
+
name: string;
|
|
108
|
+
price: number;
|
|
109
|
+
shippingEstimate: string;
|
|
110
|
+
deliveryChannel?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default async function cartShipping(props: CartShippingProps): Promise<CartShipping> {
|
|
114
|
+
const { items, postalCode, country } = props;
|
|
115
|
+
if (!items?.length || !postalCode) return { postalCode, options: [] };
|
|
116
|
+
|
|
117
|
+
const sim = await getShippingSimulation({ items, postalCode, country });
|
|
118
|
+
const logisticsInfo: Array<{ slas?: SimSla[] }> = sim?.logisticsInfo ?? [];
|
|
119
|
+
|
|
120
|
+
// Collapse SLAs across all items into one unique-by-id option list.
|
|
121
|
+
const byId = new Map<string, ShippingOption>();
|
|
122
|
+
for (const info of logisticsInfo) {
|
|
123
|
+
for (const sla of info.slas ?? []) {
|
|
124
|
+
if (byId.has(sla.id)) continue;
|
|
125
|
+
byId.set(sla.id, {
|
|
126
|
+
id: sla.id,
|
|
127
|
+
name: sla.name,
|
|
128
|
+
price: (sla.price ?? 0) / CENTS_PER_MAJOR,
|
|
129
|
+
shippingEstimate: sla.shippingEstimate,
|
|
130
|
+
deliveryChannel: sla.deliveryChannel,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { postalCode, options: [...byId.values()] };
|
|
136
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `vtex/loaders/cart/summary` — the badge loader.
|
|
3
|
+
*
|
|
4
|
+
* Returns the smallest useful cart shape: `{ orderFormId, totalItems, total }`.
|
|
5
|
+
* For a first-time visitor (no orderFormId cookie) it returns an empty summary
|
|
6
|
+
* **without touching VTEX** — this is the "don't create a cart on page load"
|
|
7
|
+
* guarantee. Only visitors who already have a cart pay a checkout API call, and
|
|
8
|
+
* that call requests just `items`+`totalizers` (via `SECTIONS_MINIMAL`).
|
|
9
|
+
*
|
|
10
|
+
* Registered in the app manifest as `vtex/loaders/cart/summary`, so it is
|
|
11
|
+
* invoke-callable from the client (`invoke.vtex.loaders.cart.summary`) in both
|
|
12
|
+
* Next.js and TanStack Start.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CartSummary } from "@decocms/apps-commerce/types";
|
|
16
|
+
import { SECTIONS_MINIMAL } from "@decocms/apps-commerce/types";
|
|
17
|
+
import { getOrCreateCartV2 } from "../../actions/checkout";
|
|
18
|
+
import { resolveOrderFormId } from "./orderFormId";
|
|
19
|
+
|
|
20
|
+
export interface CartSummaryProps {
|
|
21
|
+
/** Override the orderFormId; defaults to the request cookie. */
|
|
22
|
+
orderFormId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const EMPTY_SUMMARY: CartSummary = { orderFormId: null, totalItems: 0, total: 0 };
|
|
26
|
+
|
|
27
|
+
export default async function cartSummary(props: CartSummaryProps = {}): Promise<CartSummary> {
|
|
28
|
+
const orderFormId = resolveOrderFormId(props.orderFormId);
|
|
29
|
+
if (!orderFormId) return EMPTY_SUMMARY;
|
|
30
|
+
|
|
31
|
+
return (await getOrCreateCartV2({
|
|
32
|
+
orderFormId,
|
|
33
|
+
projection: "summary",
|
|
34
|
+
sections: SECTIONS_MINIMAL,
|
|
35
|
+
})) as CartSummary;
|
|
36
|
+
}
|