@decocms/apps-sfmc-personalization 0.0.0-stage → 8.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@decocms/apps-sfmc-personalization",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "8.1.0-next.0",
4
+ "type": "module",
5
+ "description": "Thin client for Salesforce Marketing Cloud Personalization (formerly Evergage)",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/decocms/blocks.git",
9
+ "directory": "packages/apps-sfmc-personalization"
10
+ },
11
+ "main": "./src/index.ts",
12
+ "exports": {
13
+ ".": "./src/index.ts"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "test": "vitest run --root ../.. packages/apps-sfmc-personalization/",
18
+ "typecheck": "tsc --noEmit",
19
+ "lint:unused": "knip"
20
+ },
21
+ "dependencies": {
22
+ "@decocms/blocks": "8.1.0-next.0"
23
+ },
24
+ "devDependencies": {
25
+ "knip": "^5.86.0",
26
+ "typescript": "^5.9.0"
27
+ },
28
+ "publishConfig": {
29
+ "registry": "https://registry.npmjs.org",
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The Marketing Cloud Personalization client (/next/upstream-clients): the
3
+ * Event API through the instrumented fetch, errors without bodies.
4
+ */
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { createSfmcPersonalizationClient, SfmcPersonalizationError } from "../index";
7
+
8
+ function fakeFetch(status: number, body: unknown) {
9
+ return vi.fn(
10
+ async (_input: string | URL | Request, _init?: RequestInit) =>
11
+ new Response(JSON.stringify(body), { status }),
12
+ );
13
+ }
14
+
15
+ const config = { baseUrl: "https://acme.us-1.evergage.com", dataset: "engage" };
16
+ const event = {
17
+ source: { channel: "WebServer", url: "https://shop.example.com/" },
18
+ interaction: { name: "Personalization Campaigns" },
19
+ user: { anonymousId: "anon-1" },
20
+ flags: { nonInteractive: true, doNotTrack: false },
21
+ pageView: false,
22
+ };
23
+
24
+ describe("createSfmcPersonalizationClient", () => {
25
+ it("sendEvent posts the event to the dataset and returns the campaigns", async () => {
26
+ const campaigns = { campaignResponses: [{ campaignId: "c1", payload: { products: [] } }] };
27
+ const fetch = fakeFetch(200, campaigns);
28
+ const sfmc = createSfmcPersonalizationClient(config, { fetch });
29
+
30
+ await expect(sfmc.sendEvent(event)).resolves.toEqual(campaigns);
31
+
32
+ const [url, init] = fetch.mock.calls[0];
33
+ expect(String(url)).toBe("https://acme.us-1.evergage.com/api2/event/engage");
34
+ expect(init?.method).toBe("POST");
35
+ expect(JSON.parse(String(init?.body))).toEqual(event);
36
+ });
37
+
38
+ it("throws the operation and status, never the body", async () => {
39
+ const fetch = fakeFetch(500, { error: "anon-1 not found" });
40
+ const sfmc = createSfmcPersonalizationClient(config, { fetch });
41
+
42
+ const error = await sfmc.sendEvent(event).catch((e: unknown) => e);
43
+
44
+ expect(error).toBeInstanceOf(SfmcPersonalizationError);
45
+ expect(error).toMatchObject({ operation: "sendEvent", status: 500 });
46
+ expect((error as Error).message).not.toContain("anon-1");
47
+ });
48
+ });
package/src/index.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@decocms/apps-sfmc-personalization`: a thin client for Salesforce
3
+ * Marketing Cloud Personalization (formerly Evergage). See /next/upstream-clients.
4
+ *
5
+ * Every request goes through `createInstrumentedFetch` with provider
6
+ * `sfmc-personalization`. No retries or circuit breaker. Reading the
7
+ * shopper's cookie, converting products to commerce types and caching belong
8
+ * to the site (platform templates and the framework binding).
9
+ */
10
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
11
+ import type { PersonalizationEvent, PersonalizationResponse } from "./types";
12
+
13
+ export type {
14
+ CampaignResponse,
15
+ PersonalizationEvent,
16
+ PersonalizationLineItem,
17
+ PersonalizationProduct,
18
+ PersonalizationResponse,
19
+ } from "./types";
20
+
21
+ export interface SfmcPersonalizationConfig {
22
+ /** The account's API origin, e.g. `https://<account>.<instance>.evergage.com`. */
23
+ baseUrl: string;
24
+ /** The dataset events are sent to. */
25
+ dataset: string;
26
+ }
27
+
28
+ export class SfmcPersonalizationError extends Error {
29
+ constructor(
30
+ readonly operation: string,
31
+ readonly status: number,
32
+ ) {
33
+ super(`sfmc-personalization ${operation} failed with HTTP ${status}`);
34
+ }
35
+ }
36
+
37
+ export function createSfmcPersonalizationClient(
38
+ config: SfmcPersonalizationConfig,
39
+ options: { fetch?: typeof fetch } = {},
40
+ ) {
41
+ const request = createInstrumentedFetch({
42
+ provider: "sfmc-personalization",
43
+ fetch: options.fetch,
44
+ });
45
+
46
+ return {
47
+ /** Sends an interaction and returns the campaigns it triggered. */
48
+ async sendEvent(event: PersonalizationEvent): Promise<PersonalizationResponse> {
49
+ const url = new URL(`/api2/event/${encodeURIComponent(config.dataset)}`, config.baseUrl);
50
+ const response = await request(url, {
51
+ operation: "sendEvent",
52
+ method: "POST",
53
+ headers: { "content-type": "application/json" },
54
+ body: JSON.stringify(event),
55
+ });
56
+ if (!response.ok) throw new SfmcPersonalizationError("sendEvent", response.status);
57
+ return (await response.json()) as PersonalizationResponse;
58
+ },
59
+ };
60
+ }
61
+
62
+ export type SfmcPersonalizationClient = ReturnType<typeof createSfmcPersonalizationClient>;
package/src/types.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Salesforce Marketing Cloud Personalization (formerly Evergage): request and
3
+ * response shapes of the Event API (`POST /api2/event/{dataset}`).
4
+ *
5
+ * Product fields are configured per dataset, so `PersonalizationProduct`
6
+ * keeps the common fields and accepts whatever else the dataset exposes.
7
+ */
8
+
9
+ export interface PersonalizationProduct {
10
+ id: string;
11
+ name: string;
12
+ /** List price. */
13
+ price: number;
14
+ /** Promotional price, when one is active. */
15
+ salePrice?: number;
16
+ inventoryCount: number;
17
+ imageUrls: string[];
18
+ /** Absolute product URL. */
19
+ url: string;
20
+ /** ISO 4217 currency code. */
21
+ currency: string;
22
+ description?: string;
23
+ itemType?: string;
24
+ categories?: string[];
25
+ /** Whatever else the dataset's catalog schema defines. */
26
+ [customField: string]: unknown;
27
+ }
28
+
29
+ /** A cart line, sent with cart-aware interactions. */
30
+ export interface PersonalizationLineItem {
31
+ catalogObjectType: string;
32
+ catalogObjectId: string;
33
+ quantity: number;
34
+ price: number;
35
+ }
36
+
37
+ /** The interaction a shopper had; the response holds the campaigns it triggered. */
38
+ export interface PersonalizationEvent {
39
+ source: {
40
+ channel: string;
41
+ url: string;
42
+ };
43
+ interaction: {
44
+ name: string;
45
+ lineItems?: PersonalizationLineItem[];
46
+ };
47
+ user: {
48
+ anonymousId?: string;
49
+ encryptedId?: string;
50
+ attributes?: Record<string, unknown>;
51
+ };
52
+ flags?: {
53
+ nonInteractive?: boolean;
54
+ doNotTrack?: boolean;
55
+ };
56
+ pageView?: boolean;
57
+ }
58
+
59
+ export interface CampaignResponse {
60
+ campaignId: string;
61
+ payload: {
62
+ experience?: string;
63
+ headerText?: string;
64
+ products?: PersonalizationProduct[];
65
+ userGroup?: string;
66
+ [field: string]: unknown;
67
+ };
68
+ }
69
+
70
+ export interface PersonalizationResponse {
71
+ campaignResponses?: CampaignResponse[];
72
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src/**/*"]
7
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.