@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.
@@ -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
+ }