@decocms/apps-magento 7.2.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 +47 -0
- package/src/README.md +65 -0
- package/src/__tests__/cart.test.ts +139 -0
- package/src/__tests__/client.test.ts +252 -0
- package/src/__tests__/features.test.ts +51 -0
- package/src/__tests__/graphql.test.ts +183 -0
- package/src/__tests__/newsletter-subscribe.test.ts +86 -0
- package/src/__tests__/product-stockAlert.test.ts +80 -0
- package/src/__tests__/stringifySearchCriteria.test.ts +56 -0
- package/src/__tests__/transform.test.ts +346 -0
- package/src/__tests__/user-loader.test.ts +134 -0
- package/src/__tests__/wishlist-actions.test.ts +140 -0
- package/src/__tests__/wishlist-loader.test.ts +97 -0
- package/src/actions/newsletter/subscribe.ts +40 -0
- package/src/actions/product/stockAlert.ts +67 -0
- package/src/actions/wishlist/addItem.ts +57 -0
- package/src/actions/wishlist/removeItem.ts +59 -0
- package/src/client.ts +228 -0
- package/src/index.ts +11 -0
- package/src/loaders/cart.ts +64 -0
- package/src/loaders/features.ts +16 -0
- package/src/loaders/user.ts +60 -0
- package/src/loaders/wishlist.ts +29 -0
- package/src/middleware.ts +19 -0
- package/src/types.ts +51 -0
- package/src/utils/cacheTimeControl.ts +69 -0
- package/src/utils/client/types.ts +270 -0
- package/src/utils/constants.ts +101 -0
- package/src/utils/graphql-types.ts +99 -0
- package/src/utils/graphql.ts +155 -0
- package/src/utils/stringifySearchCriteria.ts +62 -0
- package/src/utils/transform.ts +283 -0
- package/src/utils/user.ts +16 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the wishlist loader.
|
|
3
|
+
*
|
|
4
|
+
* Parity goals against deco-cx/apps/magento/loaders/wishlist.ts:
|
|
5
|
+
* - Returns null when no PHPSESSID (anonymous).
|
|
6
|
+
* - Hits /customer/section/load?sections=wishlist with the Cookie header.
|
|
7
|
+
* - Returns the wishlist payload directly when present.
|
|
8
|
+
* - Returns null when the bundle has no wishlist slice.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import { configureMagento } from "../client";
|
|
12
|
+
import wishlist from "../loaders/wishlist";
|
|
13
|
+
|
|
14
|
+
function mockResponse(body: unknown, status = 200): Response {
|
|
15
|
+
return new Response(JSON.stringify(body), {
|
|
16
|
+
status,
|
|
17
|
+
headers: { "content-type": "application/json" },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requestWithCookie(cookie: string): Request {
|
|
22
|
+
const r = new Request("http://localhost/");
|
|
23
|
+
const headers = new Headers();
|
|
24
|
+
headers.set("cookie", cookie);
|
|
25
|
+
Object.defineProperty(r, "headers", { value: headers, configurable: true });
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("wishlist loader", () => {
|
|
30
|
+
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
configureMagento({
|
|
34
|
+
baseUrl: "https://loja.example.com/",
|
|
35
|
+
apiKey: "secret",
|
|
36
|
+
storeId: 1,
|
|
37
|
+
site: "example",
|
|
38
|
+
});
|
|
39
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null when no PHPSESSID cookie is present", async () => {
|
|
47
|
+
expect(await wishlist(null, new Request("http://localhost/"))).toBeNull();
|
|
48
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("requests /customer/section/load?sections=wishlist", async () => {
|
|
52
|
+
fetchSpy.mockResolvedValue(
|
|
53
|
+
mockResponse({
|
|
54
|
+
wishlist: { counter: "0", items: [], counter_number: 0, data_id: 1 },
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
await wishlist(null, requestWithCookie("PHPSESSID=abc"));
|
|
58
|
+
const [target] = fetchSpy.mock.calls[0] as [URL];
|
|
59
|
+
expect(target.toString()).toBe(
|
|
60
|
+
"https://loja.example.com/example/customer/section/load?sections=wishlist",
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns the wishlist payload on success", async () => {
|
|
65
|
+
const wl = {
|
|
66
|
+
counter: "1",
|
|
67
|
+
counter_number: 1,
|
|
68
|
+
data_id: 1,
|
|
69
|
+
items: [
|
|
70
|
+
{
|
|
71
|
+
image: { template: "", src: "", width: 0, height: 0, alt: "" },
|
|
72
|
+
product_sku: "ABC",
|
|
73
|
+
product_id: "1",
|
|
74
|
+
product_url: "",
|
|
75
|
+
product_name: "n",
|
|
76
|
+
product_price: "10",
|
|
77
|
+
product_is_saleable_and_visible: true,
|
|
78
|
+
product_has_required_options: false,
|
|
79
|
+
add_to_cart_params: "",
|
|
80
|
+
delete_item_params: "",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
fetchSpy.mockResolvedValue(mockResponse({ wishlist: wl }));
|
|
85
|
+
expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toEqual(wl);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns null when bundle lacks a wishlist slice", async () => {
|
|
89
|
+
fetchSpy.mockResolvedValue(mockResponse({}));
|
|
90
|
+
expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns null on non-2xx response", async () => {
|
|
94
|
+
fetchSpy.mockResolvedValue(mockResponse(null, 500));
|
|
95
|
+
expect(await wishlist(null, requestWithCookie("PHPSESSID=abc"))).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento newsletter subscribe — POST a customer email to the
|
|
3
|
+
* `/V1/newsletter/subscribed` REST endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Verbatim port of `deco-cx/apps/magento/actions/newsletter/subscribe.ts`:
|
|
6
|
+
* the legacy version took `(props, _req, ctx)` and pulled `storeId` /
|
|
7
|
+
* `clientAdmin` / `site` off the App() context. The TanStack/Node port
|
|
8
|
+
* reads the same fields from `getMagentoConfig()` and uses
|
|
9
|
+
* `magentoFetch` so auth/origin/Referer headers stay aligned with the
|
|
10
|
+
* rest of the magento app. Endpoint shape and request body are
|
|
11
|
+
* unchanged so the Magento backend doesn't need re-tuning.
|
|
12
|
+
*/
|
|
13
|
+
import { getMagentoConfig, magentoFetch } from "../../client";
|
|
14
|
+
import type { NewsletterData } from "../../types";
|
|
15
|
+
|
|
16
|
+
export interface SubscribeProps {
|
|
17
|
+
/**
|
|
18
|
+
* @title Email
|
|
19
|
+
*/
|
|
20
|
+
email: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function subscribe(props: SubscribeProps): Promise<NewsletterData | null> {
|
|
24
|
+
const { site, storeId } = getMagentoConfig();
|
|
25
|
+
const path = `/rest/${encodeURIComponent(site)}/V1/newsletter/subscribed`;
|
|
26
|
+
|
|
27
|
+
const res = await magentoFetch(path, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
email: props.email,
|
|
32
|
+
store_id: Number(storeId),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!res.ok) return null;
|
|
37
|
+
const result = (await res.json()) as NewsletterData | { success: false };
|
|
38
|
+
if (!result || (result as NewsletterData).success === false) return null;
|
|
39
|
+
return result as NewsletterData;
|
|
40
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento product stock-alert subscribe — fires a GraphQL mutation so
|
|
3
|
+
* the customer gets notified when an out-of-stock SKU is replenished.
|
|
4
|
+
*
|
|
5
|
+
* Verbatim port of `deco-cx/apps/magento/actions/product/stockAlert.ts`.
|
|
6
|
+
* The Fresh version called `ctx.clientGraphql.query(...)` with a third
|
|
7
|
+
* `STALE` parameter that opted into a 1h SWR cache. The TanStack/Node
|
|
8
|
+
* port uses `magentoFetch` against `/graphql` directly; STALE was a
|
|
9
|
+
* cache hint only — the mutation is a write so no caching applies and
|
|
10
|
+
* we can drop the parameter (the original passed it but mutations are
|
|
11
|
+
* never cached server-side, so the behavior is identical).
|
|
12
|
+
*
|
|
13
|
+
* Response shape preserved: returns `{ data: { productStockAlert } }`
|
|
14
|
+
* on success or `{ error: string }` on failure.
|
|
15
|
+
*/
|
|
16
|
+
import { getMagentoConfig, magentoFetch } from "../../client";
|
|
17
|
+
import type { ProductStockAlertResponse } from "../../types";
|
|
18
|
+
|
|
19
|
+
export interface StockAlertProps {
|
|
20
|
+
product_id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
email: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MUTATION = `mutation ProductStockAlert($product_id: Int!, $name: String!, $email: String!) {
|
|
26
|
+
productStockAlert(
|
|
27
|
+
product_id: $product_id
|
|
28
|
+
name: $name
|
|
29
|
+
email: $email
|
|
30
|
+
) {
|
|
31
|
+
message
|
|
32
|
+
status
|
|
33
|
+
}
|
|
34
|
+
}`;
|
|
35
|
+
|
|
36
|
+
export default async function stockAlert(
|
|
37
|
+
props: StockAlertProps,
|
|
38
|
+
): Promise<ProductStockAlertResponse | { error: string }> {
|
|
39
|
+
const { product_id, name, email } = props;
|
|
40
|
+
const { baseUrl } = getMagentoConfig();
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const res = await magentoFetch(`${baseUrl.replace(/\/$/, "")}/graphql`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
operationName: "ProductStockAlert",
|
|
48
|
+
variables: { product_id, name, email },
|
|
49
|
+
query: MUTATION,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const json = (await res.json()) as {
|
|
54
|
+
data?: { productStockAlert: { message: string; status: boolean } };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (!json.data?.productStockAlert) {
|
|
58
|
+
return { error: "productStockAlert payload missing in GraphQL response" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { data: { productStockAlert: json.data.productStockAlert } };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
error: error instanceof Error ? error.message : "Erro desconhecido",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds a product to the customer's wishlist and returns the refreshed
|
|
3
|
+
* wishlist on success (or null on failure).
|
|
4
|
+
*
|
|
5
|
+
* Ported from `deco-cx/apps/magento/actions/wishlist/addItem.ts`. The
|
|
6
|
+
* Fresh version posted multipart FormData with `product` + `form_key`
|
|
7
|
+
* to `/wishlist/index/add/` and on success delegated to
|
|
8
|
+
* wishlistLoader to fetch the updated state. Same flow here — but the
|
|
9
|
+
* wishlistLoader is imported from the upstream port instead of the
|
|
10
|
+
* legacy in-site path.
|
|
11
|
+
*/
|
|
12
|
+
import { getCookies } from "@decocms/blocks/sdk/cookie";
|
|
13
|
+
import { getMagentoConfig, magentoFetch } from "../../client";
|
|
14
|
+
import wishlistLoader from "../../loaders/wishlist";
|
|
15
|
+
import type { Wishlist } from "../../utils/client/types";
|
|
16
|
+
import { FORM_KEY_COOKIE, SESSION_COOKIE } from "../../utils/constants";
|
|
17
|
+
import { getUserCookie } from "../../utils/user";
|
|
18
|
+
|
|
19
|
+
export interface AddWishlistItemProps {
|
|
20
|
+
productId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function addItem(
|
|
24
|
+
{ productId }: AddWishlistItemProps,
|
|
25
|
+
req: Request,
|
|
26
|
+
): Promise<Wishlist | null> {
|
|
27
|
+
try {
|
|
28
|
+
const sessionId = getUserCookie(req.headers);
|
|
29
|
+
if (!sessionId) return null;
|
|
30
|
+
|
|
31
|
+
const cookies = getCookies(req.headers);
|
|
32
|
+
const formKey = cookies[FORM_KEY_COOKIE];
|
|
33
|
+
if (!formKey) return null;
|
|
34
|
+
|
|
35
|
+
const { site } = getMagentoConfig();
|
|
36
|
+
const body = new FormData();
|
|
37
|
+
body.append("product", productId);
|
|
38
|
+
body.append("form_key", formKey);
|
|
39
|
+
|
|
40
|
+
const res = await magentoFetch(`/${encodeURIComponent(site)}/wishlist/index/add/`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
Cookie: `${SESSION_COOKIE}=${sessionId}`,
|
|
44
|
+
"x-requested-with": "XMLHttpRequest",
|
|
45
|
+
},
|
|
46
|
+
body,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!res.ok) return null;
|
|
50
|
+
const { success } = (await res.json()) as { success?: boolean };
|
|
51
|
+
if (!success) return null;
|
|
52
|
+
|
|
53
|
+
return wishlistLoader(null, req);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes a wishlist item by its Magento item id and returns the
|
|
3
|
+
* refreshed wishlist on success (or null on failure).
|
|
4
|
+
*
|
|
5
|
+
* Ported from `deco-cx/apps/magento/actions/wishlist/removeItem.ts`.
|
|
6
|
+
* The legacy code also passed an empty `uenc=""` form param — kept
|
|
7
|
+
* here byte-for-byte because Magento's wishlist controller treats the
|
|
8
|
+
* absence of `uenc` differently from an empty value and we don't want
|
|
9
|
+
* a silent behavior shift.
|
|
10
|
+
*/
|
|
11
|
+
import { getCookies } from "@decocms/blocks/sdk/cookie";
|
|
12
|
+
import { getMagentoConfig, magentoFetch } from "../../client";
|
|
13
|
+
import wishlistLoader from "../../loaders/wishlist";
|
|
14
|
+
import type { Wishlist } from "../../utils/client/types";
|
|
15
|
+
import { FORM_KEY_COOKIE, SESSION_COOKIE } from "../../utils/constants";
|
|
16
|
+
import { getUserCookie } from "../../utils/user";
|
|
17
|
+
|
|
18
|
+
export interface RemoveWishlistItemProps {
|
|
19
|
+
/** Magento wishlist item id (NOT the product id — the wishlist row id) */
|
|
20
|
+
productId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function removeItem(
|
|
24
|
+
{ productId }: RemoveWishlistItemProps,
|
|
25
|
+
req: Request,
|
|
26
|
+
): Promise<Wishlist | null> {
|
|
27
|
+
try {
|
|
28
|
+
const sessionId = getUserCookie(req.headers);
|
|
29
|
+
if (!sessionId) return null;
|
|
30
|
+
|
|
31
|
+
const cookies = getCookies(req.headers);
|
|
32
|
+
const formKey = cookies[FORM_KEY_COOKIE];
|
|
33
|
+
if (!formKey) return null;
|
|
34
|
+
|
|
35
|
+
const { site } = getMagentoConfig();
|
|
36
|
+
const body = new FormData();
|
|
37
|
+
body.append("item", productId);
|
|
38
|
+
body.append("uenc", "");
|
|
39
|
+
body.append("form_key", formKey);
|
|
40
|
+
|
|
41
|
+
const res = await magentoFetch(`/${encodeURIComponent(site)}/wishlist/index/remove/`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
Cookie: `${SESSION_COOKIE}=${sessionId}`,
|
|
45
|
+
"x-requested-with": "XMLHttpRequest",
|
|
46
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
47
|
+
},
|
|
48
|
+
body,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!res.ok) return null;
|
|
52
|
+
const { success } = (await res.json()) as { success?: boolean };
|
|
53
|
+
if (!success) return null;
|
|
54
|
+
|
|
55
|
+
return wishlistLoader(null, req);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento API client config — module-global, set once at app boot.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `vtex/client.ts`'s configureVtex/getVtexConfig pattern so the
|
|
5
|
+
* same wiring contract works across commerce apps. Sites should call
|
|
6
|
+
* `configureMagento(...)` once from their setup phase before any
|
|
7
|
+
* loader/action runs; loaders consume `getMagentoConfig()` to pick up
|
|
8
|
+
* baseUrl, auth, and feature toggles.
|
|
9
|
+
*
|
|
10
|
+
* Two reasons we don't pass config explicitly to every loader:
|
|
11
|
+
* 1. CMS-resolved loader instances don't know where the config block
|
|
12
|
+
* lives; the site's `initMagentoFromBlocks(blocks)` adapter is the
|
|
13
|
+
* single source of truth.
|
|
14
|
+
* 2. Matches the rest of @decocms/apps so a site touching VTEX and
|
|
15
|
+
* Magento has consistent muscle memory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Config shapes
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* URL-search-param filter mapping consumed by GraphQL product loaders.
|
|
24
|
+
* Each entry pairs a Magento attribute slug (`value`) with the
|
|
25
|
+
* comparison operator the storefront's URL filters use (`type`). The
|
|
26
|
+
* default mapping lives in `utils/constants.ts:DEFAULT_GRAPHQL_FILTERS`;
|
|
27
|
+
* sites extend it via the `customFilters` prop on PLP/list loaders.
|
|
28
|
+
*/
|
|
29
|
+
export interface FiltersGraphQL {
|
|
30
|
+
value: string;
|
|
31
|
+
type: "EQUAL" | "MATCH" | "RANGE";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MagentoFeatures {
|
|
35
|
+
dangerouslyDisableWishlist?: boolean;
|
|
36
|
+
dangerouslyDisableOnLoadUpdate?: boolean;
|
|
37
|
+
dangerouslyReturnNullAfterAction?: boolean;
|
|
38
|
+
dangerouslyDontReturnCartAfterAction?: boolean;
|
|
39
|
+
dangerouslyDisableOnVisibilityChangeUpdate?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MagentoImagesConfig {
|
|
43
|
+
imagesQtd: number;
|
|
44
|
+
imagesUrl: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MagentoPricingConfig {
|
|
48
|
+
maxInstallments: number;
|
|
49
|
+
minInstallmentValue: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MagentoCartConfigs {
|
|
53
|
+
countProductImageInCart?: number;
|
|
54
|
+
changeCardIdAfterCheckout?: boolean;
|
|
55
|
+
cartErrorMessages?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MagentoConfig {
|
|
59
|
+
/** Magento storefront base URL, e.g. `https://loja.granado.com.br/` */
|
|
60
|
+
baseUrl: string;
|
|
61
|
+
/** Bearer token for `Authorization` header on admin REST calls */
|
|
62
|
+
apiKey: string;
|
|
63
|
+
/** Store ID used in headers + path prefixes */
|
|
64
|
+
storeId: number;
|
|
65
|
+
/** Site/site-code used in path segments */
|
|
66
|
+
site: string;
|
|
67
|
+
/** Which Store header to send (default: "site") */
|
|
68
|
+
storeHeader?: string;
|
|
69
|
+
/** Optional opaque header value sent as `x-origin-header` */
|
|
70
|
+
originHeader?: string;
|
|
71
|
+
/** Currency code (e.g. "BRL") */
|
|
72
|
+
currencyCode?: string;
|
|
73
|
+
/** Whether to append `_suffix` to admin endpoints */
|
|
74
|
+
useSuffix?: boolean;
|
|
75
|
+
/** Behavior toggles surfaced to client hooks */
|
|
76
|
+
features?: MagentoFeatures;
|
|
77
|
+
/** Cart-specific tunables */
|
|
78
|
+
cartConfigs?: MagentoCartConfigs;
|
|
79
|
+
/** Images CDN config */
|
|
80
|
+
imagesConfig?: MagentoImagesConfig;
|
|
81
|
+
/** Pricing rules for installments display */
|
|
82
|
+
pricingConfig?: MagentoPricingConfig;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Module-global state
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
let config: MagentoConfig | null = null;
|
|
90
|
+
|
|
91
|
+
export function configureMagento(c: MagentoConfig): void {
|
|
92
|
+
config = c;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getMagentoConfig(): MagentoConfig {
|
|
96
|
+
if (!config) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"[Magento] configureMagento() must be called before loaders run. " +
|
|
99
|
+
"Wire it in your site's setup, e.g. configureMagento(blocks.magento).",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Best-effort init from a CMS block — mirrors `initVtexFromBlocks`.
|
|
107
|
+
*
|
|
108
|
+
* Resolves secret references stored in the CMS block (`apiKey`,
|
|
109
|
+
* `originHeader`) in this priority:
|
|
110
|
+
* 1. Plain string (dev override)
|
|
111
|
+
* 2. `{ get: () => string }` object (legacy)
|
|
112
|
+
* 3. `{ encrypted: "<hex>" }` decrypted via `DECO_CRYPTO_KEY` (prod)
|
|
113
|
+
* 4. `{ name: "ENV_VAR" }` → `process.env[name]` (fallback)
|
|
114
|
+
*
|
|
115
|
+
* (3) is what the production Deco CMS actually stores — admin
|
|
116
|
+
* encrypts the secret with the site's `DECO_CRYPTO_KEY` so the value
|
|
117
|
+
* never leaves the worker in plain text. Previously this init only
|
|
118
|
+
* read `process.env[name]`, which silently produced `apiKey: ""` for
|
|
119
|
+
* any site that hadn't *also* set the named env var as a CF Worker
|
|
120
|
+
* secret. Result: `Authorization: Bearer ` header missing on every
|
|
121
|
+
* request → Magento 401 → minicart/cart-related loaders dead. The
|
|
122
|
+
* shared `resolveSecret` helper from `@decocms/blocks/sdk/crypto`
|
|
123
|
+
* handles the full chain, matching how VTEX and Shopify configure
|
|
124
|
+
* themselves.
|
|
125
|
+
*
|
|
126
|
+
* Because the AES-CBC decrypt step is async, this function is now
|
|
127
|
+
* `Promise<void>` — site setups must `await` the call before any
|
|
128
|
+
* loader fires.
|
|
129
|
+
*/
|
|
130
|
+
export async function initMagentoFromBlocks(blocks: Record<string, unknown>): Promise<void> {
|
|
131
|
+
// Lazy-imported to keep `@decocms/blocks/sdk/crypto` out of the
|
|
132
|
+
// import graph for sites that wire Magento manually via
|
|
133
|
+
// `configureMagento({ apiKey: "..." })` without ever calling this
|
|
134
|
+
// helper (e.g. unit tests, CLI tools).
|
|
135
|
+
const { resolveSecret } = await import("@decocms/blocks/sdk/crypto");
|
|
136
|
+
|
|
137
|
+
const block = blocks.magento as Record<string, any> | undefined;
|
|
138
|
+
if (!block) {
|
|
139
|
+
console.warn("[Magento] No `magento` block found in CMS; skipping init.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const apiConfig = block.apiConfig ?? {};
|
|
144
|
+
|
|
145
|
+
// The env-var fallback names match the Secret block's `name` field
|
|
146
|
+
// when present. `resolveSecret` cycles through the chain documented
|
|
147
|
+
// above; an empty string here means every layer was empty, which we
|
|
148
|
+
// pass through verbatim so `buildHeaders` can detect it.
|
|
149
|
+
const extractEnvName = (value: unknown): string => {
|
|
150
|
+
if (value && typeof value === "object") {
|
|
151
|
+
const name = (value as { name?: unknown }).name;
|
|
152
|
+
if (typeof name === "string") return name;
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
};
|
|
156
|
+
const apiKeyEnvName = extractEnvName(apiConfig.apiKey);
|
|
157
|
+
const originHeaderEnvName = extractEnvName(apiConfig.originHeader);
|
|
158
|
+
|
|
159
|
+
const apiKey = (await resolveSecret(apiConfig.apiKey, apiKeyEnvName)) ?? "";
|
|
160
|
+
const originHeader = (await resolveSecret(apiConfig.originHeader, originHeaderEnvName)) ?? "";
|
|
161
|
+
|
|
162
|
+
configureMagento({
|
|
163
|
+
baseUrl: apiConfig.baseUrl ?? "",
|
|
164
|
+
apiKey,
|
|
165
|
+
storeId: apiConfig.storeId ?? 1,
|
|
166
|
+
site: apiConfig.site ?? "",
|
|
167
|
+
storeHeader: apiConfig.storeHeader,
|
|
168
|
+
originHeader,
|
|
169
|
+
currencyCode: apiConfig.currencyCode,
|
|
170
|
+
useSuffix: apiConfig.useSuffix,
|
|
171
|
+
features: block.features,
|
|
172
|
+
cartConfigs: block.cartConfigs,
|
|
173
|
+
imagesConfig: block.imagesConfig,
|
|
174
|
+
pricingConfig: block.pricingConfig,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// HTTP helpers (thin wrappers over fetch with auth pre-applied)
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
export interface MagentoFetchOpts extends RequestInit {
|
|
183
|
+
/** Whether to attach the admin Bearer token. Default true. */
|
|
184
|
+
authenticated?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildHeaders(
|
|
188
|
+
opts: MagentoFetchOpts,
|
|
189
|
+
c: MagentoConfig,
|
|
190
|
+
attachMagentoIdentity: boolean,
|
|
191
|
+
): Headers {
|
|
192
|
+
const headers = new Headers(opts.headers ?? {});
|
|
193
|
+
// `attachMagentoIdentity` is false when the request is going to a host
|
|
194
|
+
// that isn't the configured Magento backend. None of the Magento-only
|
|
195
|
+
// headers below should leak in that case: the Bearer is privileged, the
|
|
196
|
+
// `x-origin-header` is a secret that third parties shouldn't see, and a
|
|
197
|
+
// forced `Referer` would broadcast our Magento storefront URL.
|
|
198
|
+
if (!attachMagentoIdentity) return headers;
|
|
199
|
+
|
|
200
|
+
if (opts.authenticated !== false && c.apiKey) {
|
|
201
|
+
headers.set("Authorization", `Bearer ${c.apiKey}`);
|
|
202
|
+
}
|
|
203
|
+
if (c.originHeader) {
|
|
204
|
+
headers.set("x-origin-header", c.originHeader);
|
|
205
|
+
}
|
|
206
|
+
if (!headers.has("Referer")) {
|
|
207
|
+
headers.set("Referer", c.baseUrl);
|
|
208
|
+
}
|
|
209
|
+
return headers;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function magentoFetch(path: string, opts: MagentoFetchOpts = {}): Promise<Response> {
|
|
213
|
+
const c = getMagentoConfig();
|
|
214
|
+
const baseUrl = new URL(c.baseUrl);
|
|
215
|
+
const target = path.startsWith("http")
|
|
216
|
+
? new URL(path)
|
|
217
|
+
: new URL(path.startsWith("/") ? path : `/${path}`, baseUrl);
|
|
218
|
+
|
|
219
|
+
// Only attach Magento identity (Bearer, x-origin-header, forced Referer)
|
|
220
|
+
// when the request is going to the configured Magento host. An absolute
|
|
221
|
+
// URL to a different origin would otherwise leak the admin token *and*
|
|
222
|
+
// our origin/Referer secrets to that third party. Callers that genuinely
|
|
223
|
+
// want a third-party call must still pass `authenticated: false` for
|
|
224
|
+
// clarity at the call site.
|
|
225
|
+
const sameOrigin = target.origin === baseUrl.origin;
|
|
226
|
+
|
|
227
|
+
return fetch(target, { ...opts, headers: buildHeaders(opts, c, sameOrigin) });
|
|
228
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento app entry point for @decocms/apps.
|
|
3
|
+
* Re-exports client config + initializer.
|
|
4
|
+
*
|
|
5
|
+
* For actions/loaders/utils, use sub-path imports:
|
|
6
|
+
* import { features } from "@decocms/apps/magento/loaders/features"
|
|
7
|
+
* import { cart } from "@decocms/apps/magento/loaders/cart"
|
|
8
|
+
* import { magentoFetch } from "@decocms/apps/magento/client"
|
|
9
|
+
*/
|
|
10
|
+
export * from "./client";
|
|
11
|
+
export type { MagentoCart } from "./types";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento cart loader — fetches the customer's active cart by cookie.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `dataservices_cart_id` cookie from the request, calls the
|
|
5
|
+
* Magento admin REST endpoint, and returns the cart payload. Returns
|
|
6
|
+
* `null` when no cart cookie is present (anonymous visitor — expected).
|
|
7
|
+
*
|
|
8
|
+
* This is a minimal port of `deco-cx/apps/magento/loaders/cart.ts`.
|
|
9
|
+
* It omits, for now, the image-handling pipeline (`handleCartImages`)
|
|
10
|
+
* and the cart-items-with-images transform. Those depend on
|
|
11
|
+
* `utils/cache.ts` and `utils/cart.ts` from the original — both
|
|
12
|
+
* pending ports (see magento/README.md).
|
|
13
|
+
*/
|
|
14
|
+
import { getCookies } from "@decocms/blocks/sdk/cookie";
|
|
15
|
+
import { getMagentoConfig, magentoFetch } from "../client";
|
|
16
|
+
import type { MagentoCart } from "../types";
|
|
17
|
+
|
|
18
|
+
const CART_COOKIE = "dataservices_cart_id";
|
|
19
|
+
|
|
20
|
+
function readCartIdFromCookie(headers: Headers): string | null {
|
|
21
|
+
const cookies = getCookies(headers);
|
|
22
|
+
const raw = cookies[CART_COOKIE];
|
|
23
|
+
if (!raw) return null;
|
|
24
|
+
// Magento sets the cookie as `"<id>"` (JSON-encoded). Try to parse;
|
|
25
|
+
// fall back to the raw string if it isn't quoted.
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
return typeof parsed === "string" ? parsed : raw;
|
|
29
|
+
} catch {
|
|
30
|
+
return raw;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CartLoaderProps {
|
|
35
|
+
/** Override the cart id (used by checkout flows that already know it). */
|
|
36
|
+
cartId?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default async function cart(
|
|
40
|
+
props: CartLoaderProps | undefined,
|
|
41
|
+
req: Request,
|
|
42
|
+
): Promise<MagentoCart | null> {
|
|
43
|
+
const cartId = props?.cartId ?? readCartIdFromCookie(req.headers);
|
|
44
|
+
if (!cartId) return null;
|
|
45
|
+
|
|
46
|
+
const { site } = getMagentoConfig();
|
|
47
|
+
// Magento exposes the cart endpoint at /rest/:site/V1/carts/:cartId — the
|
|
48
|
+
// /rest/ prefix is mandatory and matches the Fresh/Deno original
|
|
49
|
+
// (deco-cx/apps/magento/loaders/cart.ts uses
|
|
50
|
+
// clientAdmin["GET /rest/:site/V1/carts/:cartId"]).
|
|
51
|
+
//
|
|
52
|
+
// cartId comes from the request cookie and is user-controlled. Both
|
|
53
|
+
// `site` and `cartId` are URL-encoded so neither can break out of its
|
|
54
|
+
// path segment and hit a different endpoint while the privileged
|
|
55
|
+
// Bearer token is still attached.
|
|
56
|
+
const path = `/rest/${encodeURIComponent(site)}/V1/carts/${encodeURIComponent(cartId)}`;
|
|
57
|
+
|
|
58
|
+
const res = await magentoFetch(path);
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
if (res.status === 404) return null; // expired/invalid cart cookie
|
|
61
|
+
throw new Error(`[Magento] cart loader: ${res.status} ${res.statusText}`);
|
|
62
|
+
}
|
|
63
|
+
return (await res.json()) as MagentoCart;
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magento feature flags — returns the `features` block from the resolved
|
|
3
|
+
* Magento config. Sites read this to gate optional client-side behavior
|
|
4
|
+
* (cart on-load update, wishlist visibility, on-visibility-change update,
|
|
5
|
+
* etc.) without re-deploying.
|
|
6
|
+
*
|
|
7
|
+
* In the legacy deco-cx/apps shape this lived as a 3-arg loader
|
|
8
|
+
* `(_props, _req, ctx) => ctx.features`. The TanStack/Node port uses
|
|
9
|
+
* the module-global config set by `configureMagento(...)` instead of a
|
|
10
|
+
* per-request ctx, which matches the rest of @decocms/apps.
|
|
11
|
+
*/
|
|
12
|
+
import { getMagentoConfig, type MagentoFeatures } from "../client";
|
|
13
|
+
|
|
14
|
+
export default function features(): MagentoFeatures {
|
|
15
|
+
return getMagentoConfig().features ?? {};
|
|
16
|
+
}
|