@decocms/apps-vtex 7.21.0 → 7.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps-vtex",
3
- "version": "7.21.0",
3
+ "version": "7.22.0",
4
4
  "type": "module",
5
5
  "description": "Deco commerce app: VTEX integration",
6
6
  "repository": {
@@ -51,10 +51,10 @@
51
51
  "lint:unused": "knip"
52
52
  },
53
53
  "dependencies": {
54
- "@decocms/blocks": "7.21.0",
55
- "@decocms/apps-commerce": "7.21.0",
56
- "@decocms/apps-website": "7.21.0",
57
- "@decocms/tanstack": "7.21.0"
54
+ "@decocms/blocks": "7.22.0",
55
+ "@decocms/apps-commerce": "7.22.0",
56
+ "@decocms/apps-website": "7.22.0",
57
+ "@decocms/tanstack": "7.22.0"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "react": "^19.0.0",
@@ -0,0 +1,88 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { configureVtex } from "../../client";
3
+
4
+ const simulateCartMock = vi.fn(async (props: any) => ({
5
+ logisticsInfo: [{ slas: [{ id: "sla-1" }] }],
6
+ echo: props,
7
+ }));
8
+
9
+ vi.mock("../../actions/checkout", () => ({
10
+ simulateCart: (props: any) => simulateCartMock(props),
11
+ }));
12
+
13
+ import { getShippingSimulation } from "./shipping";
14
+ import { __resetSimulationCacheForTests } from "../../utils/simulationCache";
15
+
16
+ describe("getShippingSimulation (#373)", () => {
17
+ beforeEach(() => {
18
+ configureVtex({ account: "acme", salesChannel: "1" });
19
+ __resetSimulationCacheForTests();
20
+ simulateCartMock.mockClear();
21
+ });
22
+
23
+ afterEach(() => {
24
+ __resetSimulationCacheForTests();
25
+ });
26
+
27
+ const props = {
28
+ items: [{ id: "1", quantity: 1, seller: "1" }],
29
+ postalCode: "01310-100",
30
+ };
31
+
32
+ it("calls simulateCart on a cache MISS and caches the response body", async () => {
33
+ const result = await getShippingSimulation(props);
34
+ expect(simulateCartMock).toHaveBeenCalledTimes(1);
35
+ expect(result).toEqual({ logisticsInfo: [{ slas: [{ id: "sla-1" }] }], echo: expect.anything() });
36
+ });
37
+
38
+ it("serves the cached response on a second call with the same tuple — no 2nd simulateCart call", async () => {
39
+ await getShippingSimulation(props);
40
+ await getShippingSimulation(props);
41
+ expect(simulateCartMock).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("calls simulateCart again for a different postalCode (different cache key)", async () => {
45
+ await getShippingSimulation(props);
46
+ await getShippingSimulation({ ...props, postalCode: "20040-020" });
47
+ expect(simulateCartMock).toHaveBeenCalledTimes(2);
48
+ });
49
+
50
+ it("calls simulateCart again for a different items composition", async () => {
51
+ await getShippingSimulation(props);
52
+ await getShippingSimulation({ ...props, items: [{ id: "2", quantity: 1, seller: "1" }] });
53
+ expect(simulateCartMock).toHaveBeenCalledTimes(2);
54
+ });
55
+
56
+ it("is order-insensitive for the items array (same composition, different order)", async () => {
57
+ const twoItems = {
58
+ ...props,
59
+ items: [
60
+ { id: "1", quantity: 1, seller: "1" },
61
+ { id: "2", quantity: 2, seller: "1" },
62
+ ],
63
+ };
64
+ const reordered = {
65
+ ...props,
66
+ items: [
67
+ { id: "2", quantity: 2, seller: "1" },
68
+ { id: "1", quantity: 1, seller: "1" },
69
+ ],
70
+ };
71
+ await getShippingSimulation(twoItems);
72
+ await getShippingSimulation(reordered);
73
+ expect(simulateCartMock).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("varies the cache key by salesChannel (different sc → different simulation)", async () => {
77
+ await getShippingSimulation(props);
78
+ configureVtex({ account: "acme", salesChannel: "2" });
79
+ await getShippingSimulation(props);
80
+ expect(simulateCartMock).toHaveBeenCalledTimes(2);
81
+ });
82
+
83
+ it("the cached response never carries cookies — only the plain JSON body", async () => {
84
+ const result = await getShippingSimulation(props);
85
+ expect(result).not.toHaveProperty("headers");
86
+ expect(JSON.stringify(result)).not.toContain("set-cookie");
87
+ });
88
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Cached shipping simulation loader (#373).
3
+ *
4
+ * Wraps `simulateCart` (a POST to `/api/checkout/pub/orderForms/simulation`)
5
+ * with a cache keyed only on the non-personalized inputs — items,
6
+ * postalCode, country, and salesChannel. See `../../utils/simulationCache.ts`
7
+ * for why this can't just be `vtexCachedFetch` (POST + cookie-rotation).
8
+ *
9
+ * On a cache MISS, `simulateCart` still goes through `vtexFetchWithCookies`
10
+ * as normal — cookies rotate exactly as before. Only the response BODY
11
+ * (SLAs + logistics info) is cached; cookies are never stored or replayed.
12
+ */
13
+ import { djb2 } from "@decocms/blocks/sdk/djb2";
14
+ import { simulateCart, type SimulateCartProps, type SimulationItem } from "../../actions/checkout";
15
+ import { getVtexConfig } from "../../client";
16
+ import { getSimulationCache } from "../../utils/simulationCache";
17
+
18
+ /** Shipping SLAs can shift with promotions/stock — keep the TTL short. */
19
+ const DEFAULT_TTL_SECONDS = 5 * 60;
20
+
21
+ export interface ShippingSimulationProps {
22
+ items: SimulationItem[];
23
+ postalCode: string;
24
+ country?: string;
25
+ /** Override the cache TTL (seconds). Defaults to 5 minutes. */
26
+ ttlSeconds?: number;
27
+ }
28
+
29
+ function buildCacheKey(props: ShippingSimulationProps): string {
30
+ const config = getVtexConfig();
31
+ const normalizedItems = [...props.items]
32
+ .map((item) => ({ id: String(item.id), quantity: item.quantity, seller: item.seller }))
33
+ .sort((a, b) => (a.id === b.id ? a.seller.localeCompare(b.seller) : a.id.localeCompare(b.id)));
34
+
35
+ const raw = JSON.stringify({
36
+ account: config.account,
37
+ salesChannel: config.salesChannel ?? null,
38
+ items: normalizedItems,
39
+ postalCode: props.postalCode,
40
+ country: props.country ?? config.country ?? "BRA",
41
+ });
42
+ return `shipping-sim:${djb2(raw)}`;
43
+ }
44
+
45
+ /**
46
+ * Fetch shipping SLAs for a cart, serving a cached response body when
47
+ * available for the same `{items, postalCode, salesChannel}` tuple.
48
+ */
49
+ export async function getShippingSimulation(props: ShippingSimulationProps): Promise<unknown> {
50
+ const cache = getSimulationCache();
51
+ const key = buildCacheKey(props);
52
+
53
+ const cached = await cache.get(key);
54
+ if (cached != null) {
55
+ try {
56
+ return JSON.parse(cached);
57
+ } catch {
58
+ // Corrupted cache entry — fall through to a fresh simulation.
59
+ }
60
+ }
61
+
62
+ const simulateProps: SimulateCartProps = {
63
+ items: props.items,
64
+ postalCode: props.postalCode,
65
+ country: props.country,
66
+ };
67
+ const result = await simulateCart(simulateProps);
68
+ await cache.put(key, JSON.stringify(result), props.ttlSeconds ?? DEFAULT_TTL_SECONDS);
69
+ return result;
70
+ }
@@ -1,6 +1,7 @@
1
1
  export * from "./address";
2
2
  export * from "./brands";
3
3
  export * from "./cart";
4
+ export * from "./cart/shipping";
4
5
  export * from "./catalog";
5
6
  export * from "./collections";
6
7
  export * from "./legacy";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { mapLabelledFuzzyToFuzzy } from "../productListingPage";
2
+ import { mapLabelledFuzzyToFuzzy, resolvePage } from "../productListingPage";
3
3
 
4
4
  describe("mapLabelledFuzzyToFuzzy", () => {
5
5
  it("translates 'automatic' to 'auto'", () => {
@@ -18,3 +18,34 @@ describe("mapLabelledFuzzyToFuzzy", () => {
18
18
  expect(mapLabelledFuzzyToFuzzy(undefined)).toBeUndefined();
19
19
  });
20
20
  });
21
+
22
+ describe("resolvePage (#391)", () => {
23
+ it("defaults to page 0 with no props.page and no URL page", () => {
24
+ expect(resolvePage(undefined, undefined)).toBe(0);
25
+ });
26
+
27
+ it("converts a 1-indexed URL ?page= to the 0-indexed internal page", () => {
28
+ expect(resolvePage(undefined, "3")).toBe(2);
29
+ });
30
+
31
+ it("uses props.page directly (already 0-indexed) over the URL", () => {
32
+ expect(resolvePage(5, "3")).toBe(5);
33
+ });
34
+
35
+ it("coerces a string props.page instead of silently falling back to 0", () => {
36
+ expect(resolvePage("3", undefined)).toBe(3);
37
+ });
38
+
39
+ it("falls through to the URL page when props.page is a non-finite string", () => {
40
+ expect(resolvePage("not-a-number", "3")).toBe(2);
41
+ });
42
+
43
+ it("resets a malformed ?page= to 0 instead of propagating NaN", () => {
44
+ expect(resolvePage(undefined, "abc")).toBe(0);
45
+ });
46
+
47
+ it("floors a fractional page and never returns a negative page", () => {
48
+ expect(resolvePage(2.7, undefined)).toBe(2);
49
+ expect(resolvePage(-1, undefined)).toBe(0);
50
+ });
51
+ });
@@ -28,6 +28,28 @@ export type LabelledFuzzy = "automatic" | "disabled" | "enabled";
28
28
  * @example
29
29
  * intelligentSearch({ fuzzy: mapLabelledFuzzyToFuzzy(props.fuzzy) })
30
30
  */
31
+ /**
32
+ * Resolve the 0-indexed internal `page` from the CMS prop and/or the
33
+ * 1-indexed URL `?page=` param.
34
+ *
35
+ * `props.page` may arrive as a string (e.g. auto-injected from a URL search
36
+ * param upstream) — `Number.isFinite` does NOT coerce, so a bare
37
+ * `Number.isFinite(propsPage)` check would silently treat `"3"` as invalid
38
+ * and hard-default to page 0 (see #391). Coerce first, and treat a
39
+ * non-finite coerced value as "absent" so it falls through to the URL
40
+ * parse instead of hard-defaulting.
41
+ */
42
+ export function resolvePage(propsPage: number | string | undefined, pageFromUrl: string | null | undefined): number {
43
+ const coercedPropsPage = propsPage !== undefined ? Number(propsPage) : undefined;
44
+ const rawPage =
45
+ coercedPropsPage !== undefined && Number.isFinite(coercedPropsPage)
46
+ ? coercedPropsPage
47
+ : pageFromUrl
48
+ ? Number(pageFromUrl) - 1
49
+ : 0;
50
+ return Number.isFinite(rawPage) && rawPage >= 0 ? Math.floor(rawPage) : 0;
51
+ }
52
+
31
53
  export const mapLabelledFuzzyToFuzzy = (label?: LabelledFuzzy): "0" | "1" | "auto" | undefined => {
32
54
  switch (label) {
33
55
  case "automatic":
@@ -333,9 +355,7 @@ export default async function vtexProductListingPage(props: PLPProps): Promise<a
333
355
  // raw IS API value. The URL param is already a raw value, so it passes through.
334
356
  const fuzzy =
335
357
  mapLabelledFuzzyToFuzzy(props.fuzzy) ?? pageUrl?.searchParams.get("fuzzy") ?? undefined;
336
- const pageFromUrl = pageUrl?.searchParams.get("page");
337
- const rawPage = props.page ?? (pageFromUrl ? Number(pageFromUrl) - 1 : 0);
338
- const page = Number.isFinite(rawPage) && rawPage >= 0 ? Math.floor(rawPage) : 0;
358
+ const page = resolvePage(props.page, pageUrl?.searchParams.get("page"));
339
359
 
340
360
  const { selectedFacets: cmsSelectedFacets, hideUnavailableItems = false, __pagePath } = props;
341
361
 
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ __resetSimulationCacheForTests,
4
+ getSimulationCache,
5
+ setSimulationCache,
6
+ } from "../simulationCache";
7
+
8
+ describe("simulationCache (#373)", () => {
9
+ afterEach(() => {
10
+ __resetSimulationCacheForTests();
11
+ });
12
+
13
+ it("default in-process cache: returns null on miss, then the value after put", async () => {
14
+ const cache = getSimulationCache();
15
+ expect(await cache.get("k")).toBeNull();
16
+ await cache.put("k", "v", 60);
17
+ expect(await cache.get("k")).toBe("v");
18
+ });
19
+
20
+ it("expires an entry past its TTL", async () => {
21
+ const cache = getSimulationCache();
22
+ await cache.put("k", "v", 0); // already expired
23
+ await new Promise((r) => setTimeout(r, 5));
24
+ expect(await cache.get("k")).toBeNull();
25
+ });
26
+
27
+ it("setSimulationCache injects a custom implementation", async () => {
28
+ const store = new Map<string, string>();
29
+ setSimulationCache({
30
+ get: (k) => store.get(k) ?? null,
31
+ put: (k, v) => {
32
+ store.set(k, v);
33
+ },
34
+ });
35
+ const cache = getSimulationCache();
36
+ await cache.put("k", "custom-value", 60);
37
+ expect(await cache.get("k")).toBe("custom-value");
38
+ expect(store.get("k")).toBe("custom-value");
39
+ });
40
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Injectable cache for shipping simulation responses (#373).
3
+ *
4
+ * `simulateCart`'s POST to `/api/checkout/pub/orderForms/simulation` is not
5
+ * user-personalized for a given `{items, postalCode, salesChannel}` tuple —
6
+ * every shopper with the same cart composition and zip code gets the same
7
+ * SLAs — but it goes through `vtexFetchWithCookies` (cookies can rotate on
8
+ * this endpoint), so caching the raw response risks replaying a stale
9
+ * session's cookies onto a different user.
10
+ *
11
+ * This cache stores ONLY the response body (SLAs/logistics info) — never
12
+ * cookies or session state. The default is an in-process Map (works in both
13
+ * `@decocms/tanstack` and `@decocms/nextjs`, no infra required, but is only
14
+ * warm within a single Worker isolate's lifetime). Sites that want a shared
15
+ * edge cache (Cloudflare Cache API, KV) inject their own implementation via
16
+ * `setSimulationCache` — mirrors the `setFastDeployKVGetter` pattern in
17
+ * `@decocms/blocks-admin/src/admin/decofile.ts`.
18
+ */
19
+
20
+ export interface SimulationCache {
21
+ get(key: string): Promise<string | null> | string | null;
22
+ put(key: string, value: string, ttlSeconds: number): Promise<void> | void;
23
+ }
24
+
25
+ interface InProcessEntry {
26
+ value: string;
27
+ expiresAt: number;
28
+ }
29
+
30
+ const DEFAULT_MAX_ENTRIES = 500;
31
+
32
+ function createInProcessCache(): SimulationCache {
33
+ const store = new Map<string, InProcessEntry>();
34
+
35
+ function evictIfNeeded() {
36
+ if (store.size <= DEFAULT_MAX_ENTRIES) return;
37
+ const oldestKey = store.keys().next().value;
38
+ if (oldestKey !== undefined) store.delete(oldestKey);
39
+ }
40
+
41
+ return {
42
+ get(key) {
43
+ const entry = store.get(key);
44
+ if (!entry) return null;
45
+ if (Date.now() > entry.expiresAt) {
46
+ store.delete(key);
47
+ return null;
48
+ }
49
+ return entry.value;
50
+ },
51
+ put(key, value, ttlSeconds) {
52
+ store.delete(key); // re-insert for Map's insertion-order-based eviction
53
+ store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
54
+ evictIfNeeded();
55
+ },
56
+ };
57
+ }
58
+
59
+ let cache: SimulationCache = createInProcessCache();
60
+
61
+ /** Inject a custom simulation cache (e.g. Cloudflare Cache API or KV-backed). */
62
+ export function setSimulationCache(custom: SimulationCache): void {
63
+ cache = custom;
64
+ }
65
+
66
+ export function getSimulationCache(): SimulationCache {
67
+ return cache;
68
+ }
69
+
70
+ /** @internal exported for tests */
71
+ export function __resetSimulationCacheForTests(): void {
72
+ cache = createInProcessCache();
73
+ }