diff --git a/biome.json b/biome.json index 90a1bcb..9f4cbc9 100644 --- a/biome.json +++ b/biome.json @@ -37,6 +37,7 @@ "commerce/**", "magento/**", "algolia/**", + "salesforce/**", "shopify/**", "vtex/**", "resend/**", diff --git a/knip.json b/knip.json index d92b45d..20ac8c1 100644 --- a/knip.json +++ b/knip.json @@ -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", diff --git a/package.json b/package.json index bd847e4..5798355 100644 --- a/package.json +++ b/package.json @@ -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", @@ -107,6 +111,7 @@ "commerce/", "magento/", "algolia/", + "salesforce/", "shopify/", "vtex/", "resend/", @@ -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" diff --git a/salesforce/README.md b/salesforce/README.md new file mode 100644 index 0000000..975d336 --- /dev/null +++ b/salesforce/README.md @@ -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_` 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. diff --git a/salesforce/__tests__/httpClient.test.ts b/salesforce/__tests__/httpClient.test.ts new file mode 100644 index 0000000..23f0138 --- /dev/null +++ b/salesforce/__tests__/httpClient.test.ts @@ -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; + }; +} + +type MockedFetch = typeof fetch & { + mock: { calls: [string, CallRecord["init"]][] }; +}; + +function makeFetch( + jsonBody: unknown = { ok: true }, + init: { status?: number; headers?: Record } = {}, +): 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(); + }); + }); +}); diff --git a/salesforce/__tests__/parseUserCookie.test.ts b/salesforce/__tests__/parseUserCookie.test.ts new file mode 100644 index 0000000..b43bd33 --- /dev/null +++ b/salesforce/__tests__/parseUserCookie.test.ts @@ -0,0 +1,89 @@ +/** + * Tests for utils/parseUserCookie.ts. + * + * Locks the contract that downstream loaders depend on: + * - puid wins over uuid (signed-in identity > anonymous device id) + * - uuid is used when puid is missing + * - empty / missing / malformed cookies fall back to "anonymous" + * + * The fallback is load-bearing — Evergage rejects requests with a + * missing user identifier, so an unparseable cookie must not surface + * as `{}`. + */ +import { describe, expect, it } from "vitest"; +import { parseUserCookie } from "../utils/parseUserCookie"; + +const encode = (obj: unknown) => encodeURIComponent(JSON.stringify(obj)); + +describe("parseUserCookie", () => { + it("returns anonymous fallback when cookie is undefined", () => { + expect(parseUserCookie(undefined)).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is null", () => { + expect(parseUserCookie(null)).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is empty string", () => { + expect(parseUserCookie("")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is whitespace only", () => { + expect(parseUserCookie(" ")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when cookie is not valid JSON", () => { + expect(parseUserCookie("not-json")).toEqual({ anonymousId: "anonymous" }); + }); + + it("returns anonymous fallback when JSON value is not an object", () => { + expect(parseUserCookie(encode("string-value"))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode(42))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode(null))).toEqual({ anonymousId: "anonymous" }); + }); + + it("maps puid to encryptedId", () => { + expect(parseUserCookie(encode({ puid: "user-123" }))).toEqual({ + encryptedId: "user-123", + }); + }); + + it("maps uuid to anonymousId", () => { + expect(parseUserCookie(encode({ uuid: "device-abc" }))).toEqual({ + anonymousId: "device-abc", + }); + }); + + it("prefers puid over uuid when both are present", () => { + expect(parseUserCookie(encode({ puid: "user-123", uuid: "device-abc" }))).toEqual({ + encryptedId: "user-123", + }); + }); + + it("returns anonymous fallback when puid is empty string", () => { + expect(parseUserCookie(encode({ puid: "", uuid: "device-abc" }))).toEqual({ + anonymousId: "device-abc", + }); + }); + + it("returns anonymous fallback when both puid and uuid are empty/missing", () => { + expect(parseUserCookie(encode({}))).toEqual({ anonymousId: "anonymous" }); + expect(parseUserCookie(encode({ puid: "", uuid: "" }))).toEqual({ + anonymousId: "anonymous", + }); + }); + + it("returns anonymous fallback when puid/uuid are non-string types", () => { + expect(parseUserCookie(encode({ puid: 42, uuid: true }))).toEqual({ + anonymousId: "anonymous", + }); + }); + + it("decodes URL-encoded values", () => { + // Evergage encodes the JSON with encodeURIComponent — make sure + // embedded special chars (e.g. = / : in base64 ids) round-trip. + const puid = "abc=def+ghi/jkl"; + const cookie = encodeURIComponent(JSON.stringify({ puid })); + expect(parseUserCookie(cookie)).toEqual({ encryptedId: puid }); + }); +}); diff --git a/salesforce/__tests__/transform.test.ts b/salesforce/__tests__/transform.test.ts new file mode 100644 index 0000000..e4ad57f --- /dev/null +++ b/salesforce/__tests__/transform.test.ts @@ -0,0 +1,227 @@ +/** + * Tests for utils/transform.ts. + * + * The Salesforce transformer is the only spot in the upstream package + * where consumer-specific behavior (the dataset's custom column shape) + * shows through, via the `propertyMapper` hook. The tests here lock: + * + * - default mapper outputs the always-present Evergage columns + * (itemType, categories) without dragging in dataset-specific + * fields, + * - a custom propertyMapper sees the raw Evergage product (including + * custom columns via the index signature), + * - schema.org shape is stable (productID precedence, AggregateOffer + * surface, isVariantOf hasVariant). + * + * These match the regressions the legacy `apps/salesforce/utils/ + * transform.ts` watched for over its lifetime. + */ +import { describe, expect, it } from "vitest"; +import type { SalesforceProduct } from "../types"; +import { + createProductTransformer, + type PropertyMapper, + toImages, + toOffer, +} from "../utils/transform"; + +const baseProduct = (overrides: Partial = {}): SalesforceProduct => ({ + id: "SKU-42", + name: "Test Product", + price: 100, + salePrice: 80, + inventoryCount: 7, + imageUrls: ["https://cdn.example.com/p/42.jpg"], + url: "https://loja.example.com/p/test", + currency: "BRL", + itemType: "simple", + categories: ["category-a", "category-b"], + ...overrides, +}); + +describe("toOffer", () => { + it("emits InStock when inventoryCount > 0", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.availability).toBe("https://schema.org/InStock"); + expect(offer.inventoryLevel?.value).toBe(7); + }); + + it("emits OutOfStock when inventoryCount is 0", () => { + const [offer] = toOffer({ + product: baseProduct({ inventoryCount: 0 }), + currencyCode: "BRL", + }); + expect(offer.availability).toBe("https://schema.org/OutOfStock"); + }); + + it("falls back to price when salePrice is missing", () => { + const [offer] = toOffer({ + product: baseProduct({ salePrice: undefined }), + currencyCode: "BRL", + }); + expect(offer.price).toBe(100); + }); + + it("uses salePrice as the primary price when present", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.price).toBe(80); + }); + + it("threads currencyCode into priceCurrency", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "USD" }); + expect(offer.priceCurrency).toBe("USD"); + }); + + it("emits ListPrice + SalePrice priceSpecification entries", () => { + const [offer] = toOffer({ product: baseProduct(), currencyCode: "BRL" }); + expect(offer.priceSpecification).toEqual([ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: 100, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: 80, + }, + ]); + }); +}); + +describe("toImages", () => { + it("maps each imageUrl to a schema.org ImageObject", () => { + const images = toImages( + baseProduct({ + imageUrls: ["https://cdn.example.com/a.jpg", "https://cdn.example.com/b.jpg"], + }), + ); + expect(images).toEqual([ + { + "@type": "ImageObject", + encodingFormat: "image", + alternateName: "https://cdn.example.com/a.jpg", + url: "https://cdn.example.com/a.jpg", + }, + { + "@type": "ImageObject", + encodingFormat: "image", + alternateName: "https://cdn.example.com/b.jpg", + url: "https://cdn.example.com/b.jpg", + }, + ]); + }); + + it("returns an empty array when imageUrls is empty", () => { + expect(toImages(baseProduct({ imageUrls: [] }))).toEqual([]); + }); +}); + +describe("createProductTransformer", () => { + it("uses default mapper when no propertyMapper is passed", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "itemType", value: "simple" }, + { "@type": "PropertyValue", name: "category", value: "category-a, category-b" }, + ]); + }); + + it("default mapper skips itemType when absent", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ itemType: undefined }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "category", value: "category-a, category-b" }, + ]); + }); + + it("default mapper skips categories when empty", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ categories: [] }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "itemType", value: "simple" }, + ]); + }); + + it("custom propertyMapper sees site-specific extras via index signature", () => { + const granadoMapper: PropertyMapper = (product) => [ + { "@type": "PropertyValue", name: "marca", value: String(product.Marca ?? "") }, + { "@type": "PropertyValue", name: "volume", value: String(product.Volume ?? "") }, + ]; + const transform = createProductTransformer({ propertyMapper: granadoMapper }); + const out = transform({ + product: baseProduct({ Marca: "Granado", Volume: "200ml" }), + options: { currencyCode: "BRL" }, + }); + expect(out.additionalProperty).toEqual([ + { "@type": "PropertyValue", name: "marca", value: "Granado" }, + { "@type": "PropertyValue", name: "volume", value: "200ml" }, + ]); + }); + + it("prefers idMagento over id for productID when present", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ idMagento: "9999" }), + options: { currencyCode: "BRL" }, + }); + expect(out.productID).toBe("9999"); + expect(out.sku).toBe("SKU-42"); + }); + + it("uses id for productID when idMagento is missing", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.productID).toBe("SKU-42"); + }); + + it("trims whitespace from name", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ name: " Padded Name " }), + options: { currencyCode: "BRL" }, + }); + expect(out.name).toBe("Padded Name"); + expect(out.isVariantOf?.name).toBe("Padded Name"); + }); + + it("falls back to product.currency when options.currencyCode is omitted", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ currency: "EUR" }), + options: {}, + }); + const firstOffer = out.offers?.offers[0]; + expect(firstOffer?.priceCurrency).toBe("EUR"); + }); + + it("places one variant under isVariantOf.hasVariant", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.isVariantOf?.hasVariant).toHaveLength(1); + expect(out.isVariantOf?.hasVariant?.[0].sku).toBe("SKU-42"); + }); + + it("AggregateOffer surfaces high/low correctly when on sale", () => { + const transform = createProductTransformer(); + const out = transform({ product: baseProduct(), options: { currencyCode: "BRL" } }); + expect(out.offers?.highPrice).toBe(100); + expect(out.offers?.lowPrice).toBe(80); + }); + + it("AggregateOffer high === low when no salePrice is set", () => { + const transform = createProductTransformer(); + const out = transform({ + product: baseProduct({ salePrice: undefined }), + options: { currencyCode: "BRL" }, + }); + expect(out.offers?.highPrice).toBe(100); + expect(out.offers?.lowPrice).toBe(100); + }); +}); diff --git a/salesforce/index.ts b/salesforce/index.ts new file mode 100644 index 0000000..370389b --- /dev/null +++ b/salesforce/index.ts @@ -0,0 +1,33 @@ +/** + * Salesforce Marketing Cloud Personalization (Evergage) app entry. + * + * Unlike `magento` / `algolia`, the Salesforce loaders here are + * stateless — every loader takes its `baseUrl` / `dataset` / + * `campaignId` / `cookieName` via props so the same package can power + * multiple Evergage datasets in a single worker without a global + * configure step. Sites just import the loader(s) they need. + * + * For loaders, use sub-path imports: + * import list from "@decocms/apps/salesforce/loaders/products/list" + * import listRecomended from "@decocms/apps/salesforce/loaders/products/listRecomended" + * import listCart from "@decocms/apps/salesforce/loaders/products/listCart" + * + * For the transformer (sites typically build their own `propertyMapper` + * over a dataset's custom columns): + * import { createProductTransformer } from "@decocms/apps/salesforce/utils/transform" + */ +export type { + CampaignResponse, + ParsedUserCookie, + PersonalizationBody, + PersonalizationLineItem, + PersonalizationResponse, + SalesforceProduct, +} from "./types"; +export { createHttpClient, type HttpClientOptions } from "./utils/httpClient"; +export { parseUserCookie } from "./utils/parseUserCookie"; +export { + createProductTransformer, + type ProductTransformerOptions, + type PropertyMapper, +} from "./utils/transform"; diff --git a/salesforce/loaders/products/list.ts b/salesforce/loaders/products/list.ts new file mode 100644 index 0000000..caaf239 --- /dev/null +++ b/salesforce/loaders/products/list.ts @@ -0,0 +1,139 @@ +/** + * Salesforce Marketing Cloud Personalization — campaign list loader. + * + * Hits `POST {baseUrl}/api2/event/:dataset` with the user identifier + * extracted from the Evergage cookie and returns the products attached + * to the matching campaign payload. When no cookie exists (first visit + * / parity bot / SSR), `parseUserCookie` falls back to the literal + * `anonymous` so Evergage still responds with a default campaign + * instead of erroring. + * + * Cookies are read via `getCookies()` from `@tanstack/react-start/server` + * — the request object isn't propagated through the framework's + * `commerceLoader(resolvedProps)` call, so we rely on AsyncLocalStorage + * to recover the original request from inside the deferred section + * server function context. + */ +import type { Product } from "../../../commerce/types/commerce"; +import type { + CampaignResponse, + PersonalizationBody, + PersonalizationResponse, + SalesforceProduct, +} from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListLoaderProps { + /** + * @title Personalization Base URL + * @description e.g. `https://.us-5.evergage.com` + */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** + * @title Cookie Name + * @description Cookie name Evergage drops on the browser + * (e.g. `_evga_`) + */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** Custom property mapper passed by site wrappers. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +/** + * Read the named cookie from the in-flight request. Imported lazily so + * the loader stays runtime-agnostic for unit tests (a manual cookie + * value can be passed via `__testCookieOverride`). + */ +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListLoader( + props: SalesforceListLoaderProps, + _req?: Request, +): Promise { + const { baseUrl, dataset, campaignId, cookieName, currencyCode, propertyMapper } = props; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody: PersonalizationBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { name: "Personalization Campaigns" }, + user: userData, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + // The legacy Deno loader swallowed errors and returned null. We + // keep the same return shape (so consumers don't have to handle + // rejections) but log the failure — silent null hides API outages + // and CORS regressions during parity validation. + console.error("[salesforce/products/list] failed:", (err as Error)?.message); + return null; + } +} diff --git a/salesforce/loaders/products/listCart.ts b/salesforce/loaders/products/listCart.ts new file mode 100644 index 0000000..c1bac19 --- /dev/null +++ b/salesforce/loaders/products/listCart.ts @@ -0,0 +1,151 @@ +/** + * Salesforce Marketing Cloud Personalization — cart-aware recommendations. + * + * Same wire format as `list.ts`, but the interaction name is + * `"Replace Cart"` and the request body carries the current cart's line + * items so Evergage can seed cross-sell / "frequently bought together" + * campaigns from the items already in the basket. + * + * Sites typically call this loader from a "cart drawer" / "cart side panel" + * section. The cart state itself isn't read from cookies here — sites + * resolve their own cart (Magento, Shopify, custom) and pass `items` as + * a flat array. When `items` is empty, the loader short-circuits and + * returns `null` (no cart → no cross-sell). + */ +import type { Product } from "../../../commerce/types/commerce"; +import type { + CampaignResponse, + PersonalizationBody, + PersonalizationResponse, + SalesforceProduct, +} from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListCartItem { + sku: string; + qty: number; + price: number; +} + +export interface SalesforceListCartProps { + /** + * @title Cart Items + * @description Items currently in the user's cart. Site loaders resolve + * this from their commerce backend (Magento, Shopify, etc.) and pass + * it through. + */ + items: SalesforceListCartItem[]; + /** @title Personalization Base URL */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** @title Cookie Name */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** + * @title Fallback Title + * @description Shown when the campaign payload has no `headerText` + * (e.g. Granado uses the configured `label` from the CMS block). + */ + title?: string; + /** Custom property mapper. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListCartResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListCartLoader( + props: SalesforceListCartProps, + _req?: Request, +): Promise { + const { items, baseUrl, dataset, campaignId, cookieName, currencyCode, title, propertyMapper } = + props; + + if (!items?.length) return null; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody: PersonalizationBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { + name: "Replace Cart", + lineItems: items.map(({ sku, qty, price }) => ({ + catalogObjectType: "Product", + catalogObjectId: sku, + quantity: qty, + price, + })), + }, + user: userData, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText || title, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + console.error("[salesforce/products/listCart] failed:", (err as Error)?.message); + return null; + } +} diff --git a/salesforce/loaders/products/listRecomended.ts b/salesforce/loaders/products/listRecomended.ts new file mode 100644 index 0000000..f45a9a6 --- /dev/null +++ b/salesforce/loaders/products/listRecomended.ts @@ -0,0 +1,121 @@ +/** + * Salesforce Marketing Cloud Personalization — recommendations loader. + * + * Same wire format as `list.ts`, but the campaign Evergage runs is the + * "related products" one and the request body includes the + * `viewedProductId` attribute so Evergage can use the in-context PDP + * to seed its recommendation model. + * + * Used on PDP / PDC pages; the upstream site passes the resolved + * `ProductDetailsPage` so we can read `product.sku`. When no product + * context is available the loader still sends the campaign request — + * Evergage will return its default fallback list. + */ +import type { Product, ProductDetailsPage } from "../../../commerce/types/commerce"; +import type { CampaignResponse, PersonalizationResponse, SalesforceProduct } from "../../types"; +import { createHttpClient } from "../../utils/httpClient"; +import { parseUserCookie } from "../../utils/parseUserCookie"; +import { createProductTransformer, type PropertyMapper } from "../../utils/transform"; + +export interface SalesforceListRecommendedProps { + /** @title Product Id (resolved PDP) */ + productId: ProductDetailsPage | null; + /** @title Personalization Base URL */ + baseUrl: string; + /** @title Personalization Dataset */ + dataset: string; + /** @title Campaign Id */ + campaignId: string; + /** @title Cookie Name */ + cookieName: string; + /** @title Currency Code (ISO 4217) */ + currencyCode?: string; + /** Custom property mapper. */ + propertyMapper?: PropertyMapper; +} + +export interface SalesforceListRecommendedResult { + "@type": "ProductList"; + list: Product[]; + additionalData: { + title?: string; + campaignId: string; + experienceId?: string; + userGroup?: string; + }; +} + +async function readCookie(cookieName: string): Promise { + try { + const { getCookies } = await import("@tanstack/react-start/server"); + const cookies = getCookies(); + return cookies?.[cookieName]; + } catch { + return undefined; + } +} + +export default async function salesforceListRecommendedLoader( + props: SalesforceListRecommendedProps, + _req?: Request, +): Promise { + const { baseUrl, dataset, productId, cookieName, campaignId, currencyCode, propertyMapper } = + props; + + try { + const rawCookie = await readCookie(cookieName); + const userData = parseUserCookie(rawCookie); + + const client = createHttpClient({ + base: baseUrl, + headers: { "x-requested-with": "XMLHttpRequest" }, + }); + + const requestBody = { + source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` }, + interaction: { name: "Personalization Campaigns" }, + user: { + ...userData, + attributes: { viewedProductId: productId?.product?.sku ?? "" }, + }, + flags: { nonInteractive: true, doNotTrack: false }, + pageView: false, + }; + + const response = (await client["POST /api2/event/:dataset"]( + { dataset }, + { body: requestBody }, + ).then((res: { json: () => Promise }) => + res.json(), + )) as PersonalizationResponse; + + const payload = + response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId) + ?.payload ?? response.campaignResponses?.[0]?.payload; + + if (!payload?.products?.length) { + return null; + } + + const transform = createProductTransformer({ propertyMapper }); + + return { + "@type": "ProductList", + list: payload.products.map((product: SalesforceProduct) => + transform({ + product, + options: { currencyCode: currencyCode ?? product.currency }, + }), + ), + additionalData: { + title: payload.headerText, + campaignId, + experienceId: payload.experience, + userGroup: payload.userGroup, + }, + }; + } catch (err) { + console.error("[salesforce/products/listRecomended] failed:", (err as Error)?.message); + return null; + } +} diff --git a/salesforce/types.ts b/salesforce/types.ts new file mode 100644 index 0000000..2fe7762 --- /dev/null +++ b/salesforce/types.ts @@ -0,0 +1,108 @@ +/** + * Salesforce Marketing Cloud Personalization (Evergage) — request/response + * shapes for the campaign personalization API. + * + * The Evergage product schema is configurable per dataset (each customer + * chooses which fields to expose), so `SalesforceProduct` keeps a minimal + * required surface and accepts arbitrary extras via index signature. + * Site-level transformers can downcast to their own product shape to + * read store-specific custom fields (e.g. `marca`, `linha`, brand-tags). + */ + +export interface SalesforceProduct { + /** Internal Evergage id (string token, often a SKU or catalog id). */ + id: string; + /** Display name. */ + name: string; + /** Base price (list). */ + price: number; + /** Discounted price; falls back to `price` when no promotion is active. */ + salePrice?: number; + /** Stock count from Evergage's catalog feed. */ + inventoryCount: number; + /** Pre-built absolute URLs of product images. */ + imageUrls: string[]; + /** Canonical product URL (full origin + path). */ + url: string; + /** ISO 4217 currency code (e.g. "BRL"). */ + currency: string; + /** Common Evergage column — short product description. */ + description?: string; + /** Optional cross-system identifier (e.g. Magento `entity_id`). */ + idMagento?: string; + /** Item type tag (e.g. "configurable", "simple"). */ + itemType?: string; + /** Generic category trail (Evergage stores these as arrays). */ + categories?: string[]; + /** Site-specific extras — Evergage exposes whatever the dataset schema + * defines (e.g. `Marca`, `Volume`, `Linha`, `freeShipping`). The + * product transformer can read these via the attributeMapper hook. */ + [customField: string]: unknown; +} + +/** + * Single line item in a cart-interaction request body. Evergage uses + * these to seed cart-aware recommendations ("people who bought X also + * bought Y") and abandoned-cart campaigns. + */ +export interface PersonalizationLineItem { + catalogObjectType: string; + catalogObjectId: string; + quantity: number; + price: number; +} + +/** + * Body sent to `POST {baseUrl}/api2/event/:dataset`. The Evergage API is + * "interaction"-based — every request describes an interaction the user + * had, and the response contains the campaigns triggered by that + * interaction (recommendations, popups, etc.). + * + * `interaction.lineItems` is only used by cart-aware campaigns; PDP / + * homepage requests omit it. `user.attributes` carries page-context + * hints (e.g. `viewedProductId` for related-product campaigns). + */ +export interface PersonalizationBody { + source: { + channel: string; + url: string; + }; + interaction: { + name: string; + lineItems?: PersonalizationLineItem[]; + }; + user: { + anonymousId?: string; + encryptedId?: string; + attributes?: Record; + }; + flags: { + nonInteractive: boolean; + doNotTrack: boolean; + }; + pageView: boolean; +} + +export interface CampaignResponse { + campaignId: string; + payload: { + experience?: string; + headerText?: string; + products?: SalesforceProduct[]; + userGroup?: string; + }; +} + +export interface PersonalizationResponse { + campaignResponses?: CampaignResponse[]; +} + +/** + * Cookie shape Evergage drops on the browser (`puid` = persistent user + * id after sign-in, `uuid` = anonymous device id). The site loaders + * read this cookie to send the right user identifier on each request. + */ +export interface ParsedUserCookie { + encryptedId?: string; + anonymousId?: string; +} diff --git a/salesforce/utils/httpClient.ts b/salesforce/utils/httpClient.ts new file mode 100644 index 0000000..c1a6c94 --- /dev/null +++ b/salesforce/utils/httpClient.ts @@ -0,0 +1,154 @@ +/** + * Typed HTTP client compatible with deco-cx/apps' `createHttpClient` + * signature. Accepts both the simple `.get(path)` / `.post(path, body)` + * shape and the indexed-route shape used by legacy Deno loaders: + * + * ```ts + * const client = createHttpClient({ base: "https://api.example.com" }); + * await client["POST /api2/event/:dataset"]({ dataset: "prod" }, { body }); + * await client.get("/healthcheck"); + * ``` + * + * Runtime-agnostic: only requires the global `fetch`. Works on + * Cloudflare Workers, Bun, Deno, and modern Node — no `node:http` + * dependency. Returns `{ json, ok, status, headers }` from indexed + * routes (mirrors the legacy `apps/utils/http.ts` shape so existing + * loaders that call `.then(res => res.json())` keep working). + */ + +export interface HttpClientOptions { + base: string; + headers?: Record | Headers; + fetcher?: typeof fetch; +} + +interface IndexedRouteResponse { + json: () => Promise; + ok: boolean; + status: number; + headers: Headers; +} + +export function createHttpClient<_Routes = unknown>(options: HttpClientOptions) { + const base = options.base.replace(/\/$/, ""); + const fetchImpl = options.fetcher ?? fetch; + const defaultHeaders: Record = + options.headers instanceof Headers + ? Object.fromEntries(options.headers.entries()) + : (options.headers ?? {}); + + const handler: ProxyHandler> = { + get(_target, prop) { + if (prop === "get") { + return async (path: string, init?: RequestInit): Promise => { + const res = await fetchImpl(`${base}${path}`, { + ...init, + headers: { + ...defaultHeaders, + ...((init?.headers as Record) ?? {}), + }, + }); + return res.json() as Promise; + }; + } + if (prop === "post") { + return async (path: string, body: unknown, init?: RequestInit): Promise => { + const res = await fetchImpl(`${base}${path}`, { + method: "POST", + ...init, + headers: { + "Content-Type": "application/json", + ...defaultHeaders, + ...((init?.headers as Record) ?? {}), + }, + body: JSON.stringify(body), + }); + return res.json() as Promise; + }; + } + if (typeof prop === "string" && /^(GET|POST|PUT|PATCH|DELETE)\s+/.test(prop)) { + const spaceIdx = prop.indexOf(" "); + const method = prop.slice(0, spaceIdx); + let apiPath = prop.slice(spaceIdx + 1); + + return async ( + params: Record = {}, + init?: RequestInit & { body?: unknown }, + ): Promise => { + const cleanParams = { ...params }; + + // `:name` path placeholders — replaced with the matching + // param value, then removed from the body/query object. + for (const [key, value] of Object.entries(cleanParams)) { + const placeholder = `:${key}`; + if (apiPath.includes(placeholder) && value != null) { + apiPath = apiPath.replace(placeholder, encodeURIComponent(String(value))); + delete cleanParams[key]; + } + } + + // Legacy `*name` placeholders (Deno-era convention). + const starMatch = apiPath.match(/\*(\w+)/); + if (starMatch) { + const paramName = starMatch[1]; + if (cleanParams[paramName] != null) { + apiPath = apiPath.replace(`*${paramName}`, String(cleanParams[paramName])); + delete cleanParams[paramName]; + } else { + apiPath = apiPath.replace(/\/\*\w+/, ""); + } + } + + let url = `${base}${apiPath}`; + + if (method === "GET") { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(cleanParams)) { + if (v !== undefined && v !== null) sp.set(k, String(v)); + } + const qs = sp.toString(); + if (qs) url += (url.includes("?") ? "&" : "?") + qs; + } + + // Indexed-route callers can either embed the body in the + // remaining params (legacy style) OR pass `{ body }` in the + // second arg (newer call sites). The `body` key in init + // takes precedence when present. + const explicitBody = init && "body" in init ? init.body : undefined; + const fetchBody = + method === "GET" + ? undefined + : explicitBody !== undefined + ? JSON.stringify(explicitBody) + : Object.keys(cleanParams).length > 0 + ? JSON.stringify(cleanParams) + : undefined; + + const fetchInit: RequestInit = { + method, + ...(init ?? {}), + headers: { + "Content-Type": "application/json", + ...defaultHeaders, + ...(init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers as Record) ?? {})), + }, + ...(fetchBody !== undefined ? { body: fetchBody } : {}), + }; + + const res = await fetchImpl(url, fetchInit); + return { + json: () => res.json() as Promise, + ok: res.ok, + status: res.status, + headers: res.headers, + }; + }; + } + return undefined; + }, + }; + + return new Proxy({} as Record, handler) as Record; +} diff --git a/salesforce/utils/parseUserCookie.ts b/salesforce/utils/parseUserCookie.ts new file mode 100644 index 0000000..904c890 --- /dev/null +++ b/salesforce/utils/parseUserCookie.ts @@ -0,0 +1,41 @@ +/** + * Decode the JSON-encoded user identifier cookie set by Salesforce + * Marketing Cloud Personalization (Evergage) on the browser. + * + * Evergage stores `{ puid?: string, uuid?: string }` URL-encoded: + * - `puid` (persistent user id) is present after the user signs in + * - `uuid` (anonymous device id) is dropped on first visit + * + * The personalization API expects EITHER `encryptedId` (mapped from + * `puid`) OR `anonymousId` (mapped from `uuid`). When the cookie is + * missing or malformed, we fall back to the literal "anonymous" so + * the API still returns a default campaign instead of erroring. + */ +import type { ParsedUserCookie } from "../types"; + +const ANONYMOUS_FALLBACK: ParsedUserCookie = { anonymousId: "anonymous" }; + +export function parseUserCookie(rawCookie: string | undefined | null): ParsedUserCookie { + if (!rawCookie?.trim()) return ANONYMOUS_FALLBACK; + + try { + const decoded = decodeURIComponent(rawCookie); + const parsed: unknown = JSON.parse(decoded); + if (typeof parsed !== "object" || parsed === null) return ANONYMOUS_FALLBACK; + + const { puid, uuid } = parsed as { puid?: unknown; uuid?: unknown }; + + // puid wins over uuid when both are present — once a user signs in, + // every request should be attributed to their persistent identity + // so cross-device personalization works. + if (typeof puid === "string" && puid.length > 0) { + return { encryptedId: puid }; + } + if (typeof uuid === "string" && uuid.length > 0) { + return { anonymousId: uuid }; + } + return ANONYMOUS_FALLBACK; + } catch { + return ANONYMOUS_FALLBACK; + } +} diff --git a/salesforce/utils/transform.ts b/salesforce/utils/transform.ts new file mode 100644 index 0000000..9b48db4 --- /dev/null +++ b/salesforce/utils/transform.ts @@ -0,0 +1,156 @@ +/** + * Map a Salesforce Personalization product into a schema.org `Product` + * suitable for ProductShelf / ProductCard / PDP components. + * + * Most fields (id, name, price, images, currency) are common to every + * Evergage dataset. The `additionalProperty` list, however, is fully + * dataset-specific — each customer chooses which catalog columns are + * exposed by Evergage (`Marca`, `Volume`, `Linha`, `freeShipping` etc.). + * Sites pass a `propertyMapper` to project their custom columns into + * `schema.org/PropertyValue[]`; the default mapper produces the + * standard fields that exist on every Evergage product (`itemType`, + * `categories`). + */ +import type { Offer, Product, PropertyValue } from "../../commerce/types/commerce"; +import type { SalesforceProduct } from "../types"; + +const IN_STOCK = "https://schema.org/InStock"; +const OUT_OF_STOCK = "https://schema.org/OutOfStock"; + +export type PropertyMapper = (product: SalesforceProduct) => PropertyValue[]; + +export interface ProductTransformerOptions { + /** + * Project dataset-specific Evergage columns into schema.org + * `PropertyValue[]`. Receives the raw Evergage product (including + * any custom fields via the index signature). Returns an empty + * array by default — sites that want brand / volume / line tags on + * their cards pass a mapper. + */ + propertyMapper?: PropertyMapper; +} + +const DEFAULT_PROPERTY_MAPPER: PropertyMapper = (product) => { + const out: PropertyValue[] = []; + if (product.itemType) { + out.push({ "@type": "PropertyValue", name: "itemType", value: product.itemType }); + } + if (product.categories?.length) { + out.push({ + "@type": "PropertyValue", + name: "category", + value: product.categories.join(", "), + }); + } + return out; +}; + +export function toOffer({ + product, + currencyCode, +}: { + product: SalesforceProduct; + currencyCode?: string; +}): Offer[] { + const productPrice = product.price; + const productSalePrice = product.salePrice || productPrice; + return [ + { + "@type": "Offer", + availability: product.inventoryCount > 0 ? IN_STOCK : OUT_OF_STOCK, + inventoryLevel: { value: product.inventoryCount }, + itemCondition: "https://schema.org/NewCondition", + price: productSalePrice, + priceCurrency: currencyCode, + priceSpecification: [ + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/ListPrice", + price: productPrice, + }, + { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/SalePrice", + price: productSalePrice, + }, + ], + sku: product.id, + }, + ]; +} + +export function toImages(product: SalesforceProduct) { + return product.imageUrls.map((url) => ({ + "@type": "ImageObject" as const, + encodingFormat: "image", + alternateName: url, + url, + })); +} + +/** + * Returns a `toProduct` function bound to the dataset's property + * mapper. Sites typically construct one transformer at module level + * and reuse it across loaders. + */ +export function createProductTransformer( + options: ProductTransformerOptions = {}, +): (input: { product: SalesforceProduct; options: { currencyCode?: string } }) => Product { + const mapProperties = options.propertyMapper ?? DEFAULT_PROPERTY_MAPPER; + + return ({ product, options: opts }) => { + const offers = toOffer({ product, currencyCode: opts.currencyCode ?? product.currency }); + const sku = product.id; + // `idMagento` (cross-system identifier) wins when present — + // downstream code keys product detail pages off `productID`. + const productID = (product.idMagento as string | undefined) ?? sku; + const productPrice = product.price; + const productSalePrice = product.salePrice || productPrice; + const productUrl = product.url; + const additionalProperty = mapProperties(product); + + const variantTemplate: Product = { + "@type": "Product", + productID, + sku, + url: productUrl, + name: product.name.trim(), + gtin: sku, + offers: { + "@type": "AggregateOffer", + highPrice: productPrice, + lowPrice: productSalePrice, + offerCount: offers.length, + offers, + }, + }; + + return { + "@type": "Product", + productID, + sku, + url: productUrl, + name: product.name.trim(), + gtin: sku, + aggregateRating: { "@type": "AggregateRating", reviewCount: undefined }, + isVariantOf: { + "@type": "ProductGroup", + productGroupID: productID, + url: productUrl, + name: product.name.trim(), + model: "", + additionalProperty, + hasVariant: [variantTemplate], + }, + additionalProperty, + image: toImages(product), + offers: { + "@type": "AggregateOffer", + highPrice: productPrice, + lowPrice: productSalePrice, + offerCount: offers.length, + offers, + }, + }; + }; +}