@decocms/apps-vtex 7.24.0 → 7.26.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/loaders/intelligentSearch/productDetailsPage.ts +20 -2
- package/src/loaders/paths/PDPDefaultPath.ts +51 -0
- package/src/manifest.gen.ts +48 -38
- package/src/utils/__tests__/cartProjection.test.ts +118 -0
- package/src/utils/cartProjection.ts +96 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { ProductDetailsPage } from "@decocms/apps-commerce/types";
|
|
8
8
|
import { getVtexConfig, vtexCachedFetch } from "../../client";
|
|
9
|
+
import PDPDefaultPath from "../paths/PDPDefaultPath";
|
|
9
10
|
import { searchBySlug } from "../../utils/slugCache";
|
|
10
11
|
import { pickSku, toProductPage } from "../../utils/transform";
|
|
11
12
|
import type { LegacyProduct } from "../../utils/types";
|
|
@@ -42,10 +43,27 @@ export default async function vtexProductDetailsPage(
|
|
|
42
43
|
variantIncludeImage = true,
|
|
43
44
|
variantIncludeInventory = true,
|
|
44
45
|
} = props;
|
|
45
|
-
if (!slug) return null;
|
|
46
46
|
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
// Only the admin hits the PDP at its raw route pattern (`/:slug/p`), so the
|
|
49
|
+
// `slug` route-param resolves to the literal placeholder `:slug` — there's
|
|
50
|
+
// no real product in the URL, so render the best seller as a preview.
|
|
51
|
+
//
|
|
52
|
+
// This is NOT a general fallback: a concrete slug that resolves to no
|
|
53
|
+
// product (e.g. `/dssadals/p`) must still 404 like any missing product, and
|
|
54
|
+
// a genuinely absent slug must NOT pull in an unrelated product either. So
|
|
55
|
+
// we trigger ONLY on the `:`-prefixed placeholder, never on empty/missing.
|
|
56
|
+
// Mirrors deco-cx/apps' PDPDefaultPath behavior.
|
|
57
|
+
let effectiveSlug = slug;
|
|
58
|
+
|
|
59
|
+
if (slug?.startsWith(":")) {
|
|
60
|
+
const defaultPaths = await PDPDefaultPath({ count: 1 });
|
|
61
|
+
effectiveSlug = defaultPaths?.possiblePaths[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!effectiveSlug) return null;
|
|
65
|
+
|
|
66
|
+
const linkText = effectiveSlug.replace(/\/p$/, "").replace(/^\//, "").toLowerCase();
|
|
49
67
|
const config = getVtexConfig();
|
|
50
68
|
const sc = config.salesChannel;
|
|
51
69
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PDP default-path resolver.
|
|
3
|
+
*
|
|
4
|
+
* Ported from deco-cx/apps (`vtex/loaders/paths/PDPDefaultPath.ts`). When a
|
|
5
|
+
* Product page is previewed/configured in the CMS, it is resolved at its raw
|
|
6
|
+
* route pattern (`/:slug/p`), so the `slug` route-param comes through as the
|
|
7
|
+
* literal placeholder `:slug` — there is no concrete product in the URL. Rather
|
|
8
|
+
* than render a 404, we fall back to a real product — the best seller — so the
|
|
9
|
+
* PDP has something to show in the admin.
|
|
10
|
+
*
|
|
11
|
+
* We query Intelligent Search for the top-selling, in-stock product and return
|
|
12
|
+
* its `linkText` as the slug the PDP loader should render.
|
|
13
|
+
*/
|
|
14
|
+
import { intelligentSearch } from "../../client";
|
|
15
|
+
|
|
16
|
+
export interface PDPDefaultPathProps {
|
|
17
|
+
/** How many candidate paths to fetch. Defaults to 1. */
|
|
18
|
+
count?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DefaultPathResult {
|
|
22
|
+
possiblePaths: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function PDPDefaultPath(
|
|
26
|
+
props: PDPDefaultPathProps = {},
|
|
27
|
+
): Promise<DefaultPathResult | null> {
|
|
28
|
+
const { count = 1 } = props;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const data = await intelligentSearch<{ products?: Array<{ linkText?: string }> }>(
|
|
32
|
+
"/product_search/",
|
|
33
|
+
{
|
|
34
|
+
page: "1",
|
|
35
|
+
count: String(count),
|
|
36
|
+
query: "",
|
|
37
|
+
sort: "orders:desc",
|
|
38
|
+
hideUnavailableItems: "true",
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const possiblePaths = (data.products ?? [])
|
|
43
|
+
.map((p) => p.linkText)
|
|
44
|
+
.filter((linkText): linkText is string => Boolean(linkText));
|
|
45
|
+
|
|
46
|
+
return { possiblePaths };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("[VTEX] PDPDefaultPath error:", error);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/manifest.gen.ts
CHANGED
|
@@ -17,6 +17,11 @@ import * as loaders_address from "./loaders/address";
|
|
|
17
17
|
import * as loaders_autocomplete from "./loaders/autocomplete";
|
|
18
18
|
import * as loaders_brands from "./loaders/brands";
|
|
19
19
|
import * as loaders_cart from "./loaders/cart";
|
|
20
|
+
import * as loaders_cart_attachments from "./loaders/cart/attachments";
|
|
21
|
+
import * as loaders_cart_full from "./loaders/cart/full";
|
|
22
|
+
import * as loaders_cart_gifts from "./loaders/cart/gifts";
|
|
23
|
+
import * as loaders_cart_shipping from "./loaders/cart/shipping";
|
|
24
|
+
import * as loaders_cart_summary from "./loaders/cart/summary";
|
|
20
25
|
import * as loaders_catalog from "./loaders/catalog";
|
|
21
26
|
import * as loaders_collections from "./loaders/collections";
|
|
22
27
|
import * as loaders_legacy from "./loaders/legacy";
|
|
@@ -35,44 +40,49 @@ import * as loaders_wishlistProducts from "./loaders/wishlistProducts";
|
|
|
35
40
|
import * as loaders_workflow from "./loaders/workflow";
|
|
36
41
|
|
|
37
42
|
const manifest = {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
43
|
+
name: "vtex",
|
|
44
|
+
loaders: {
|
|
45
|
+
"vtex/loaders/address": loaders_address,
|
|
46
|
+
"vtex/loaders/autocomplete": loaders_autocomplete,
|
|
47
|
+
"vtex/loaders/brands": loaders_brands,
|
|
48
|
+
"vtex/loaders/cart": loaders_cart,
|
|
49
|
+
"vtex/loaders/cart/summary": loaders_cart_summary,
|
|
50
|
+
"vtex/loaders/cart/full": loaders_cart_full,
|
|
51
|
+
"vtex/loaders/cart/shipping": loaders_cart_shipping,
|
|
52
|
+
"vtex/loaders/cart/gifts": loaders_cart_gifts,
|
|
53
|
+
"vtex/loaders/cart/attachments": loaders_cart_attachments,
|
|
54
|
+
"vtex/loaders/catalog": loaders_catalog,
|
|
55
|
+
"vtex/loaders/collections": loaders_collections,
|
|
56
|
+
"vtex/loaders/legacy": loaders_legacy,
|
|
57
|
+
"vtex/loaders/logistics": loaders_logistics,
|
|
58
|
+
"vtex/loaders/navbar": loaders_navbar,
|
|
59
|
+
"vtex/loaders/orders": loaders_orders,
|
|
60
|
+
"vtex/loaders/pageType": loaders_pageType,
|
|
61
|
+
"vtex/loaders/payment": loaders_payment,
|
|
62
|
+
"vtex/loaders/profile": loaders_profile,
|
|
63
|
+
"vtex/loaders/promotion": loaders_promotion,
|
|
64
|
+
"vtex/loaders/search": loaders_search,
|
|
65
|
+
"vtex/loaders/session": loaders_session,
|
|
66
|
+
"vtex/loaders/user": loaders_user,
|
|
67
|
+
"vtex/loaders/wishlist": loaders_wishlist,
|
|
68
|
+
"vtex/loaders/wishlistProducts": loaders_wishlistProducts,
|
|
69
|
+
"vtex/loaders/workflow": loaders_workflow,
|
|
70
|
+
},
|
|
71
|
+
actions: {
|
|
72
|
+
"vtex/actions/address": actions_address,
|
|
73
|
+
"vtex/actions/analytics/sendEvent": actions_analytics_sendEvent,
|
|
74
|
+
"vtex/actions/auth": actions_auth,
|
|
75
|
+
"vtex/actions/checkout": actions_checkout,
|
|
76
|
+
"vtex/actions/masterData": actions_masterData,
|
|
77
|
+
"vtex/actions/misc": actions_misc,
|
|
78
|
+
"vtex/actions/newsletter": actions_newsletter,
|
|
79
|
+
"vtex/actions/orders": actions_orders,
|
|
80
|
+
"vtex/actions/profile": actions_profile,
|
|
81
|
+
"vtex/actions/session": actions_session,
|
|
82
|
+
"vtex/actions/trigger": actions_trigger,
|
|
83
|
+
"vtex/actions/wishlist": actions_wishlist,
|
|
84
|
+
},
|
|
85
|
+
sections: {},
|
|
76
86
|
} as const;
|
|
77
87
|
|
|
78
88
|
export type Manifest = typeof manifest;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `projectOrderForm` — the server-side fragmentation of a VTEX
|
|
3
|
+
* OrderForm into the requested `CartProjection`. Pure function, no I/O.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Minicart } from "@decocms/apps-commerce/types";
|
|
7
|
+
import { describe, expect, it } from "vitest";
|
|
8
|
+
import type { OrderForm } from "../../types";
|
|
9
|
+
import { projectOrderForm } from "../cartProjection";
|
|
10
|
+
|
|
11
|
+
function makeOrderForm(overrides: Partial<OrderForm> = {}): OrderForm {
|
|
12
|
+
return {
|
|
13
|
+
orderFormId: "of-123",
|
|
14
|
+
salesChannel: "1",
|
|
15
|
+
loggedIn: false,
|
|
16
|
+
isCheckedIn: false,
|
|
17
|
+
allowManualPrice: false,
|
|
18
|
+
canEditData: true,
|
|
19
|
+
ignoreProfileData: false,
|
|
20
|
+
value: 15000, // R$150.00 in cents
|
|
21
|
+
messages: [],
|
|
22
|
+
items: [
|
|
23
|
+
{
|
|
24
|
+
id: "sku-1",
|
|
25
|
+
productId: "prod-1",
|
|
26
|
+
name: "Camiseta",
|
|
27
|
+
skuName: "Camiseta P Azul",
|
|
28
|
+
imageUrl: "http://img.example/x.jpg",
|
|
29
|
+
detailUrl: "/camiseta/p",
|
|
30
|
+
price: 10000,
|
|
31
|
+
listPrice: 12000,
|
|
32
|
+
sellingPrice: 10000,
|
|
33
|
+
quantity: 1,
|
|
34
|
+
seller: "1",
|
|
35
|
+
uniqueId: "u1",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "sku-2",
|
|
39
|
+
productId: "prod-2",
|
|
40
|
+
name: "Boné",
|
|
41
|
+
skuName: "Boné U Preto",
|
|
42
|
+
imageUrl: "https://img.example/y.jpg",
|
|
43
|
+
detailUrl: "/bone/u",
|
|
44
|
+
price: 5000,
|
|
45
|
+
listPrice: 5000,
|
|
46
|
+
sellingPrice: 5000,
|
|
47
|
+
quantity: 2,
|
|
48
|
+
seller: "1",
|
|
49
|
+
uniqueId: "u2",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
totalizers: [
|
|
53
|
+
{ id: "Items", name: "Items", value: 20000 },
|
|
54
|
+
{ id: "Discounts", name: "Discounts", value: -5000 },
|
|
55
|
+
],
|
|
56
|
+
shippingData: null,
|
|
57
|
+
clientProfileData: null,
|
|
58
|
+
paymentData: null,
|
|
59
|
+
marketingData: null,
|
|
60
|
+
...overrides,
|
|
61
|
+
} as OrderForm;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("projectOrderForm", () => {
|
|
65
|
+
it("none → { ok: true } and discards the payload", () => {
|
|
66
|
+
expect(projectOrderForm(makeOrderForm(), "none")).toEqual({ ok: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("summary → orderFormId, summed quantities, total in major units", () => {
|
|
70
|
+
const r = projectOrderForm(makeOrderForm(), "summary") as {
|
|
71
|
+
orderFormId: string;
|
|
72
|
+
totalItems: number;
|
|
73
|
+
total: number;
|
|
74
|
+
};
|
|
75
|
+
expect(r.orderFormId).toBe("of-123");
|
|
76
|
+
expect(r.totalItems).toBe(3); // 1 + 2
|
|
77
|
+
expect(r.total).toBe(150); // 15000 cents → 150
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("summary+items → summary plus slim, https-normalized items", () => {
|
|
81
|
+
const r = projectOrderForm(makeOrderForm(), "summary+items") as {
|
|
82
|
+
totalItems: number;
|
|
83
|
+
items: Array<{ item_name: string; image: string; price: number; quantity: number }>;
|
|
84
|
+
};
|
|
85
|
+
expect(r.totalItems).toBe(3);
|
|
86
|
+
expect(r.items).toHaveLength(2);
|
|
87
|
+
expect(r.items[0]).toMatchObject({
|
|
88
|
+
item_name: "Camiseta",
|
|
89
|
+
item_variant: "Camiseta P Azul",
|
|
90
|
+
image: "https://img.example/x.jpg", // http → https
|
|
91
|
+
price: 100, // 10000 cents
|
|
92
|
+
quantity: 1,
|
|
93
|
+
});
|
|
94
|
+
// slim projection must NOT leak the full VTEX item fields
|
|
95
|
+
expect(r.items[0]).not.toHaveProperty("uniqueId");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("minicart → canonical Minicart with major-unit totals", () => {
|
|
99
|
+
const r = projectOrderForm(makeOrderForm(), "minicart") as Minicart<OrderForm>;
|
|
100
|
+
expect(r.storefront.items).toHaveLength(2);
|
|
101
|
+
expect(r.storefront.total).toBe(150);
|
|
102
|
+
expect(r.original.orderFormId).toBe("of-123");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("raw → the untouched OrderForm", () => {
|
|
106
|
+
const of = makeOrderForm();
|
|
107
|
+
expect(projectOrderForm(of, "raw")).toBe(of);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("summary handles an empty cart without NaN", () => {
|
|
111
|
+
const r = projectOrderForm(
|
|
112
|
+
makeOrderForm({ items: [], value: 0, totalizers: [] }),
|
|
113
|
+
"summary",
|
|
114
|
+
) as { totalItems: number; total: number };
|
|
115
|
+
expect(r.totalItems).toBe(0);
|
|
116
|
+
expect(r.total).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project a VTEX OrderForm down to a requested `CartProjection`.
|
|
3
|
+
*
|
|
4
|
+
* The VTEX checkout API always returns the whole OrderForm from mutation
|
|
5
|
+
* endpoints (bounded only by `expectedOrderFormSections`) — there is no
|
|
6
|
+
* delta/patch response. So "return the minimum" is enforced **server-side**:
|
|
7
|
+
* we take whatever VTEX gave us and shape it into `none` / `summary` /
|
|
8
|
+
* `summary+items` / `minicart` / `raw` before it reaches the browser.
|
|
9
|
+
*
|
|
10
|
+
* Pure function — no I/O, fully unit-testable. All monetary values in the
|
|
11
|
+
* projected output are in major units (VTEX is cents).
|
|
12
|
+
*
|
|
13
|
+
* @see `@decocms/apps-commerce/types/cart` for the agnostic contract.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CartItemSlim,
|
|
18
|
+
CartOk,
|
|
19
|
+
CartProjection,
|
|
20
|
+
CartSummary,
|
|
21
|
+
CartSummaryWithItems,
|
|
22
|
+
Minicart,
|
|
23
|
+
} from "@decocms/apps-commerce/types";
|
|
24
|
+
import type { OrderForm, OrderFormItem } from "../types";
|
|
25
|
+
import { type VtexOrderFormToMinicartOptions, vtexOrderFormToMinicart } from "./minicart";
|
|
26
|
+
|
|
27
|
+
const CENTS_PER_MAJOR = 100;
|
|
28
|
+
|
|
29
|
+
function fromCents(cents: number | undefined | null): number {
|
|
30
|
+
if (cents == null || !Number.isFinite(cents)) return 0;
|
|
31
|
+
return cents / CENTS_PER_MAJOR;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Sum of line quantities. */
|
|
35
|
+
function countItems(orderForm: OrderForm): number {
|
|
36
|
+
return (orderForm.items ?? []).reduce((sum, i) => sum + (i.quantity ?? 0), 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toSummary(orderForm: OrderForm): CartSummary {
|
|
40
|
+
return {
|
|
41
|
+
orderFormId: orderForm.orderFormId ?? null,
|
|
42
|
+
totalItems: countItems(orderForm),
|
|
43
|
+
total: fromCents(orderForm.value),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Map a VTEX line to the slim, analytics-compatible `CartItemSlim`. */
|
|
48
|
+
function toSlimItem(item: OrderFormItem): CartItemSlim {
|
|
49
|
+
return {
|
|
50
|
+
item_id: item.id,
|
|
51
|
+
item_name: item.name ?? item.skuName ?? "",
|
|
52
|
+
item_variant: item.skuName,
|
|
53
|
+
image: item.imageUrl?.replace(/^http:/, "https:") ?? "",
|
|
54
|
+
price: fromCents(item.sellingPrice ?? item.price),
|
|
55
|
+
quantity: item.quantity,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ProjectOrderFormOptions = VtexOrderFormToMinicartOptions;
|
|
60
|
+
|
|
61
|
+
/** The projected result type, with VTEX-concrete `minicart`/`raw` payloads. */
|
|
62
|
+
export type VtexCartProjectionResult =
|
|
63
|
+
| CartOk
|
|
64
|
+
| CartSummary
|
|
65
|
+
| CartSummaryWithItems
|
|
66
|
+
| Minicart<OrderForm>
|
|
67
|
+
| OrderForm;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shape a VTEX OrderForm into the requested projection.
|
|
71
|
+
*
|
|
72
|
+
* @param orderForm - Raw OrderForm from a VTEX checkout call.
|
|
73
|
+
* @param projection - Desired client-facing shape.
|
|
74
|
+
* @param opts - Storefront overrides forwarded to the `minicart` projection.
|
|
75
|
+
*/
|
|
76
|
+
export function projectOrderForm(
|
|
77
|
+
orderForm: OrderForm,
|
|
78
|
+
projection: CartProjection,
|
|
79
|
+
opts: ProjectOrderFormOptions = {},
|
|
80
|
+
): VtexCartProjectionResult {
|
|
81
|
+
switch (projection) {
|
|
82
|
+
case "none":
|
|
83
|
+
return { ok: true };
|
|
84
|
+
case "summary":
|
|
85
|
+
return toSummary(orderForm);
|
|
86
|
+
case "summary+items":
|
|
87
|
+
return {
|
|
88
|
+
...toSummary(orderForm),
|
|
89
|
+
items: (orderForm.items ?? []).map(toSlimItem),
|
|
90
|
+
};
|
|
91
|
+
case "minicart":
|
|
92
|
+
return vtexOrderFormToMinicart(orderForm, opts);
|
|
93
|
+
case "raw":
|
|
94
|
+
return orderForm;
|
|
95
|
+
}
|
|
96
|
+
}
|