Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"commerce/**",
"magento/**",
"algolia/**",
"salesforce/**",
"shopify/**",
"vtex/**",
"resend/**",
Expand Down
3 changes: 3 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"shopify/loaders/*.ts",
"shopify/utils/*.ts",
"shopify/utils/**/*.ts",
"salesforce/*.ts",
"salesforce/loaders/products/*.ts",
"salesforce/utils/*.ts",
"commerce/components/*.tsx",
"commerce/sdk/*.ts",
"commerce/types/*.ts",
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
"./algolia/client": "./algolia/client.ts",
"./algolia/types": "./algolia/types.ts",
"./algolia/loaders/*": "./algolia/loaders/*.ts",
"./salesforce": "./salesforce/index.ts",
"./salesforce/types": "./salesforce/types.ts",
"./salesforce/utils/*": "./salesforce/utils/*.ts",
"./salesforce/loaders/products/*": "./salesforce/loaders/products/*.ts",
"./vtex": "./vtex/index.ts",
"./vtex/commerceLoaders": "./vtex/commerceLoaders.ts",
"./vtex/mod": "./vtex/mod.ts",
Expand Down Expand Up @@ -107,6 +111,7 @@
"commerce/",
"magento/",
"algolia/",
"salesforce/",
"shopify/",
"vtex/",
"resend/",
Expand All @@ -125,6 +130,7 @@
"peerDependencies": {
"@decocms/start": ">=5.3.0",
"@tanstack/react-query": ">=5",
"@tanstack/react-start": ">=1",
"algoliasearch": "^5",
"react": ">=18",
"react-dom": ">=18"
Expand Down
122 changes: 122 additions & 0 deletions salesforce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Salesforce Marketing Cloud Personalization app

Ports the Salesforce Marketing Cloud Personalization (formerly Evergage)
campaign API from `deco-cx/apps/salesforce` (Fresh/Deno) to
`@decocms/apps/salesforce` (TanStack Start/Node), following the same
shape as `algolia/` and `magento/`.

## Status

**Initial scaffold** — covers the campaign personalization read path
(homepage shelves, PDP recommendations, cart cross-sell). No write
actions yet; the legacy `apps/salesforce/actions/*` aren't ported.

## What's here

- `types.ts` — `SalesforceProduct` (open-ended via index signature so
dataset-specific columns survive), `PersonalizationBody`,
`PersonalizationResponse`, `CampaignResponse`, `ParsedUserCookie`.
- `utils/parseUserCookie.ts` — decode Evergage's URL-encoded JSON cookie
to `{ encryptedId | anonymousId }`. Falls back to
`{ anonymousId: "anonymous" }` so first-visit requests still hit the
default campaign instead of erroring.
- `utils/httpClient.ts` — runtime-agnostic Proxy client supporting the
legacy indexed-route syntax (`client["POST /api2/event/:dataset"]`).
Compatible with Cloudflare Workers / Bun / Deno / modern Node — only
requires the global `fetch`.
- `utils/transform.ts` — `createProductTransformer({ propertyMapper? })`
factory that converts an Evergage product into a schema.org `Product`.
Sites pass a `propertyMapper` to project their dataset's custom
columns (e.g. `Marca`, `Volume`, `Linha`) into `PropertyValue[]`.
- `loaders/products/list.ts` — campaign personalization shelf (homepage).
- `loaders/products/listRecomended.ts` — related-product recommendations
(PDP / PDC).
- `loaders/products/listCart.ts` — cart-aware cross-sell.

## Why no `configureSalesforce`

Unlike Magento / VTEX / Algolia (one global SDK client per site), the
Salesforce loaders here are stateless — every call is a plain `fetch`
POST to `/api2/event/:dataset`, and a single site might run multiple
datasets (homepage uses dataset A, PDP uses dataset B). Passing
`baseUrl` / `dataset` / `campaignId` / `cookieName` via loader props
keeps the package free of hidden global state and lets the CMS block
remain the single source of truth.

## Wiring in a site

```ts
// src/packs/salesforce/products/list.ts (site-side wrapper)
import salesforceList from "@decocms/apps/salesforce/loaders/products/list";
import { granadoPropertyMapper } from "../attributeMapper";

interface SiteProps {
baseUrl: string;
dataset: string;
campaignId: string;
cookieName: string;
}

export default function loader(props: SiteProps) {
return salesforceList({
...props,
currencyCode: "BRL",
propertyMapper: granadoPropertyMapper,
});
}
```

```ts
// src/packs/salesforce/attributeMapper.ts (site-specific)
import type { PropertyMapper } from "@decocms/apps/salesforce";

export const granadoPropertyMapper: PropertyMapper = (product) => {
const props: { "@type": "PropertyValue"; name: string; value: unknown }[] = [];
if (product.Marca) props.push({ "@type": "PropertyValue", name: "marca", value: product.Marca });
if (product.Volume) props.push({ "@type": "PropertyValue", name: "volume", value: product.Volume });
if (product.Linha) props.push({ "@type": "PropertyValue", name: "linha", value: product.Linha });
if (product.tag) props.push({ "@type": "PropertyValue", name: "tag__phebo", value: product.tag });
if (product.freeShipping) {
props.push({ "@type": "PropertyValue", name: "free_shipping__phebo", value: product.freeShipping });
}
if (product.SubCategoria) {
props.push({ "@type": "PropertyValue", name: "subCategory", value: product.SubCategoria });
}
return props;
};
```

## Cookie reading and the framework gap

The campaign API expects a user identifier on every request. Evergage
drops `_evga_<account>` on the browser, encoded as JSON. The loaders
read this cookie via `getCookies()` from `@tanstack/react-start/server`
— which reads the in-flight request out of `AsyncLocalStorage` rather
than from an explicit `req` argument.

Why ALS instead of a `req` parameter? In `@decocms/start`, the
`commerceLoader(resolvedProps)` resolver path doesn't forward the
request object to the loader (see `commerce/resolve.ts`), so loaders
that need cookies have to recover them from the framework's stored
context. This matches what the in-tree Magento loaders already do.

Sites that aren't using `@decocms/start` (raw TanStack Start, tests,
etc.) can call the loader from inside a server function — the same
`getCookies()` resolution works as long as the call is inside a
TanStack Start request boundary.

## Error handling

Each loader wraps its POST in a try/catch that logs to `console.error`
under `[salesforce/products/...]` and returns `null`. The legacy Deno
loaders returned silent `null` on error; we keep the same return shape
(callers don't need to handle rejections) but surface the error so API
outages and CORS regressions don't hide behind an empty shelf.

## Tests

```bash
bun run test salesforce
```

The unit tests mock `fetch` directly — no Evergage credentials needed.
201 changes: 201 additions & 0 deletions salesforce/__tests__/httpClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* Tests for utils/httpClient.ts.
*
* The legacy `apps/utils/http.ts` indexed-route Proxy is what every
* Deno-era loader called into, so this test file pins the call shapes
* existing site code still uses:
* - `client["POST /api2/event/:dataset"]({ dataset }, { body })`
* - `client.get(path)` / `client.post(path, body)` convenience methods
* - `:name` placeholder substitution and `*name` legacy Deno-era syntax
* - default `x-requested-with` header propagation
* - `{ json, ok, status, headers }` response shape
*
* `fetch` is mocked so we never touch the network.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../utils/httpClient";

interface CallRecord {
url: string;
init: {
method?: string;
body?: string;
headers: Record<string, string>;
};
}

type MockedFetch = typeof fetch & {
mock: { calls: [string, CallRecord["init"]][] };
};

function makeFetch(
jsonBody: unknown = { ok: true },
init: { status?: number; headers?: Record<string, string> } = {},
): MockedFetch {
return vi.fn(async () => ({
json: async () => jsonBody,
ok: (init.status ?? 200) < 400,
status: init.status ?? 200,
headers: new Headers(init.headers ?? {}),
})) as unknown as MockedFetch;
}

function callAt(fetcher: MockedFetch, idx: number): CallRecord {
const call = fetcher.mock.calls[idx];
return { url: call[0], init: call[1] };
}

describe("createHttpClient", () => {
let originalFetch: typeof fetch;

beforeEach(() => {
originalFetch = globalThis.fetch;
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

it("strips trailing slash from base URL", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com/", fetcher });
await client.get("/health");
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/health");
});

it("uses global fetch when no fetcher is provided", async () => {
globalThis.fetch = makeFetch({ ok: true });
const client = createHttpClient({ base: "https://api.example.com" });
const result = await client.get("/health");
expect(result).toEqual({ ok: true });
expect(globalThis.fetch).toHaveBeenCalledOnce();
});

describe(".get / .post convenience methods", () => {
it(".get returns parsed JSON directly", async () => {
const fetcher = makeFetch({ items: [1, 2, 3] });
const client = createHttpClient({ base: "https://api.example.com", fetcher });
const result = await client.get("/things");
expect(result).toEqual({ items: [1, 2, 3] });
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/things");
});

it(".post sends JSON body with Content-Type header", async () => {
const fetcher = makeFetch({ created: true });
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client.post("/things", { name: "thing" });
const { init } = callAt(fetcher, 0);
expect(init.method).toBe("POST");
expect(init.body).toBe(JSON.stringify({ name: "thing" }));
expect(init.headers["Content-Type"]).toBe("application/json");
});

it("merges default headers from options into request", async () => {
const fetcher = makeFetch();
const client = createHttpClient({
base: "https://api.example.com",
fetcher,
headers: { "x-requested-with": "XMLHttpRequest" },
});
await client.post("/things", { name: "x" });
const { init } = callAt(fetcher, 0);
expect(init.headers["x-requested-with"]).toBe("XMLHttpRequest");
});

it("accepts a Headers object for default headers", async () => {
const fetcher = makeFetch();
const client = createHttpClient({
base: "https://api.example.com",
fetcher,
headers: new Headers({ "x-token": "abc" }),
});
await client.get("/things");
const { init } = callAt(fetcher, 0);
expect(init.headers["x-token"]).toBe("abc");
});
});

describe("indexed-route Proxy syntax", () => {
it("replaces `:name` path placeholders with the matching param", async () => {
const fetcher = makeFetch({ campaignResponses: [] });
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["POST /api2/event/:dataset"]({ dataset: "production" }, { body: { foo: 1 } });
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/api2/event/production");
});

it("url-encodes placeholder values", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["GET /catalog/:slug"]({ slug: "shoes & boots" });
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog/shoes%20%26%20boots");
});

it("removes leftover `*name` placeholders when no value is provided", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["GET /catalog/*path"]({});
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog");
});

it("substitutes `*name` placeholders when value is present", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["GET /catalog/*path"]({ path: "shoes/boots" });
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/catalog/shoes/boots");
});

it("serialises remaining params as the body on non-GET requests", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["POST /api2/event/:dataset"]({
dataset: "production",
extraField: "value",
});
const { init } = callAt(fetcher, 0);
expect(JSON.parse(init.body ?? "")).toEqual({ extraField: "value" });
});

it("prefers explicit { body } over remaining params", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["POST /api2/event/:dataset"](
{ dataset: "production", ignored: "yes" },
{ body: { real: "body" } },
);
const { init } = callAt(fetcher, 0);
expect(JSON.parse(init.body ?? "")).toEqual({ real: "body" });
});

it("encodes remaining params as querystring on GET", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["GET /search"]({ q: "shoes", limit: 12 });
const call = callAt(fetcher, 0);
expect(call.url).toBe("https://api.example.com/search?q=shoes&limit=12");
expect(call.init.body).toBeUndefined();
});

it("returns { json, ok, status, headers } from indexed routes", async () => {
const fetcher = makeFetch({ result: 1 }, { status: 201, headers: { etag: "abc" } });
const client = createHttpClient({ base: "https://api.example.com", fetcher });
const response = await client["POST /things"]({ name: "x" });
expect(response.status).toBe(201);
expect(response.ok).toBe(true);
expect(response.headers.get("etag")).toBe("abc");
expect(await response.json()).toEqual({ result: 1 });
});

it("appends query when no params remain but route already has `?`", async () => {
const fetcher = makeFetch();
const client = createHttpClient({ base: "https://api.example.com", fetcher });
await client["GET /search?fixed=1"]({ q: "shoes" });
expect(callAt(fetcher, 0).url).toBe("https://api.example.com/search?fixed=1&q=shoes");
});

it("returns undefined for unknown property accesses", () => {
const client = createHttpClient({ base: "https://api.example.com" });
// biome-ignore lint/suspicious/noExplicitAny: type-erased access for test
expect((client as any).somethingMadeUp).toBeUndefined();
});
});
});
Loading
Loading