@decocms/apps-salesforce 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/__tests__/httpClient.test.ts +201 -0
- package/src/__tests__/parseUserCookie.test.ts +89 -0
- package/src/__tests__/transform.test.ts +227 -0
- package/src/index.ts +33 -0
- package/src/loaders/products/list.ts +139 -0
- package/src/loaders/products/listCart.ts +151 -0
- package/src/loaders/products/listRecomended.ts +121 -0
- package/src/types.ts +108 -0
- package/src/utils/httpClient.ts +154 -0
- package/src/utils/parseUserCookie.ts +41 -0
- package/src/utils/transform.ts +156 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salesforce Marketing Cloud Personalization — campaign list loader.
|
|
3
|
+
*
|
|
4
|
+
* Hits `POST {baseUrl}/api2/event/:dataset` with the user identifier
|
|
5
|
+
* extracted from the Evergage cookie and returns the products attached
|
|
6
|
+
* to the matching campaign payload. When no cookie exists (first visit
|
|
7
|
+
* / parity bot / SSR), `parseUserCookie` falls back to the literal
|
|
8
|
+
* `anonymous` so Evergage still responds with a default campaign
|
|
9
|
+
* instead of erroring.
|
|
10
|
+
*
|
|
11
|
+
* Cookies are read via `getCookies()` from `@tanstack/react-start/server`
|
|
12
|
+
* — the request object isn't propagated through the framework's
|
|
13
|
+
* `commerceLoader(resolvedProps)` call, so we rely on AsyncLocalStorage
|
|
14
|
+
* to recover the original request from inside the deferred section
|
|
15
|
+
* server function context.
|
|
16
|
+
*/
|
|
17
|
+
import type { Product } from "@decocms/apps-commerce/types";
|
|
18
|
+
import type {
|
|
19
|
+
CampaignResponse,
|
|
20
|
+
PersonalizationBody,
|
|
21
|
+
PersonalizationResponse,
|
|
22
|
+
SalesforceProduct,
|
|
23
|
+
} from "../../types";
|
|
24
|
+
import { createHttpClient } from "../../utils/httpClient";
|
|
25
|
+
import { parseUserCookie } from "../../utils/parseUserCookie";
|
|
26
|
+
import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
|
|
27
|
+
|
|
28
|
+
export interface SalesforceListLoaderProps {
|
|
29
|
+
/**
|
|
30
|
+
* @title Personalization Base URL
|
|
31
|
+
* @description e.g. `https://<account>.us-5.evergage.com`
|
|
32
|
+
*/
|
|
33
|
+
baseUrl: string;
|
|
34
|
+
/** @title Personalization Dataset */
|
|
35
|
+
dataset: string;
|
|
36
|
+
/** @title Campaign Id */
|
|
37
|
+
campaignId: string;
|
|
38
|
+
/**
|
|
39
|
+
* @title Cookie Name
|
|
40
|
+
* @description Cookie name Evergage drops on the browser
|
|
41
|
+
* (e.g. `_evga_<accountSlug>`)
|
|
42
|
+
*/
|
|
43
|
+
cookieName: string;
|
|
44
|
+
/** @title Currency Code (ISO 4217) */
|
|
45
|
+
currencyCode?: string;
|
|
46
|
+
/** Custom property mapper passed by site wrappers. */
|
|
47
|
+
propertyMapper?: PropertyMapper;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SalesforceListResult {
|
|
51
|
+
"@type": "ProductList";
|
|
52
|
+
list: Product[];
|
|
53
|
+
additionalData: {
|
|
54
|
+
title?: string;
|
|
55
|
+
campaignId: string;
|
|
56
|
+
experienceId?: string;
|
|
57
|
+
userGroup?: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the named cookie from the in-flight request. Imported lazily so
|
|
63
|
+
* the loader stays runtime-agnostic for unit tests (a manual cookie
|
|
64
|
+
* value can be passed via `__testCookieOverride`).
|
|
65
|
+
*/
|
|
66
|
+
async function readCookie(cookieName: string): Promise<string | undefined> {
|
|
67
|
+
try {
|
|
68
|
+
const { getCookies } = await import("@tanstack/react-start/server");
|
|
69
|
+
const cookies = getCookies();
|
|
70
|
+
return cookies?.[cookieName];
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default async function salesforceListLoader(
|
|
77
|
+
props: SalesforceListLoaderProps,
|
|
78
|
+
_req?: Request,
|
|
79
|
+
): Promise<SalesforceListResult | null> {
|
|
80
|
+
const { baseUrl, dataset, campaignId, cookieName, currencyCode, propertyMapper } = props;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const rawCookie = await readCookie(cookieName);
|
|
84
|
+
const userData = parseUserCookie(rawCookie);
|
|
85
|
+
|
|
86
|
+
const client = createHttpClient({
|
|
87
|
+
base: baseUrl,
|
|
88
|
+
headers: { "x-requested-with": "XMLHttpRequest" },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const requestBody: PersonalizationBody = {
|
|
92
|
+
source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
|
|
93
|
+
interaction: { name: "Personalization Campaigns" },
|
|
94
|
+
user: userData,
|
|
95
|
+
flags: { nonInteractive: true, doNotTrack: false },
|
|
96
|
+
pageView: false,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const response = (await client["POST /api2/event/:dataset"](
|
|
100
|
+
{ dataset },
|
|
101
|
+
{ body: requestBody },
|
|
102
|
+
).then((res: { json: () => Promise<PersonalizationResponse> }) =>
|
|
103
|
+
res.json(),
|
|
104
|
+
)) as PersonalizationResponse;
|
|
105
|
+
|
|
106
|
+
const payload =
|
|
107
|
+
response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
|
|
108
|
+
?.payload ?? response.campaignResponses?.[0]?.payload;
|
|
109
|
+
|
|
110
|
+
if (!payload?.products?.length) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const transform = createProductTransformer({ propertyMapper });
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"@type": "ProductList",
|
|
118
|
+
list: payload.products.map((product: SalesforceProduct) =>
|
|
119
|
+
transform({
|
|
120
|
+
product,
|
|
121
|
+
options: { currencyCode: currencyCode ?? product.currency },
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
additionalData: {
|
|
125
|
+
title: payload.headerText,
|
|
126
|
+
campaignId,
|
|
127
|
+
experienceId: payload.experience,
|
|
128
|
+
userGroup: payload.userGroup,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// The legacy Deno loader swallowed errors and returned null. We
|
|
133
|
+
// keep the same return shape (so consumers don't have to handle
|
|
134
|
+
// rejections) but log the failure — silent null hides API outages
|
|
135
|
+
// and CORS regressions during parity validation.
|
|
136
|
+
console.error("[salesforce/products/list] failed:", (err as Error)?.message);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salesforce Marketing Cloud Personalization — cart-aware recommendations.
|
|
3
|
+
*
|
|
4
|
+
* Same wire format as `list.ts`, but the interaction name is
|
|
5
|
+
* `"Replace Cart"` and the request body carries the current cart's line
|
|
6
|
+
* items so Evergage can seed cross-sell / "frequently bought together"
|
|
7
|
+
* campaigns from the items already in the basket.
|
|
8
|
+
*
|
|
9
|
+
* Sites typically call this loader from a "cart drawer" / "cart side panel"
|
|
10
|
+
* section. The cart state itself isn't read from cookies here — sites
|
|
11
|
+
* resolve their own cart (Magento, Shopify, custom) and pass `items` as
|
|
12
|
+
* a flat array. When `items` is empty, the loader short-circuits and
|
|
13
|
+
* returns `null` (no cart → no cross-sell).
|
|
14
|
+
*/
|
|
15
|
+
import type { Product } from "@decocms/apps-commerce/types";
|
|
16
|
+
import type {
|
|
17
|
+
CampaignResponse,
|
|
18
|
+
PersonalizationBody,
|
|
19
|
+
PersonalizationResponse,
|
|
20
|
+
SalesforceProduct,
|
|
21
|
+
} from "../../types";
|
|
22
|
+
import { createHttpClient } from "../../utils/httpClient";
|
|
23
|
+
import { parseUserCookie } from "../../utils/parseUserCookie";
|
|
24
|
+
import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
|
|
25
|
+
|
|
26
|
+
export interface SalesforceListCartItem {
|
|
27
|
+
sku: string;
|
|
28
|
+
qty: number;
|
|
29
|
+
price: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SalesforceListCartProps {
|
|
33
|
+
/**
|
|
34
|
+
* @title Cart Items
|
|
35
|
+
* @description Items currently in the user's cart. Site loaders resolve
|
|
36
|
+
* this from their commerce backend (Magento, Shopify, etc.) and pass
|
|
37
|
+
* it through.
|
|
38
|
+
*/
|
|
39
|
+
items: SalesforceListCartItem[];
|
|
40
|
+
/** @title Personalization Base URL */
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
/** @title Personalization Dataset */
|
|
43
|
+
dataset: string;
|
|
44
|
+
/** @title Campaign Id */
|
|
45
|
+
campaignId: string;
|
|
46
|
+
/** @title Cookie Name */
|
|
47
|
+
cookieName: string;
|
|
48
|
+
/** @title Currency Code (ISO 4217) */
|
|
49
|
+
currencyCode?: string;
|
|
50
|
+
/**
|
|
51
|
+
* @title Fallback Title
|
|
52
|
+
* @description Shown when the campaign payload has no `headerText`
|
|
53
|
+
* (e.g. Granado uses the configured `label` from the CMS block).
|
|
54
|
+
*/
|
|
55
|
+
title?: string;
|
|
56
|
+
/** Custom property mapper. */
|
|
57
|
+
propertyMapper?: PropertyMapper;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SalesforceListCartResult {
|
|
61
|
+
"@type": "ProductList";
|
|
62
|
+
list: Product[];
|
|
63
|
+
additionalData: {
|
|
64
|
+
title?: string;
|
|
65
|
+
campaignId: string;
|
|
66
|
+
experienceId?: string;
|
|
67
|
+
userGroup?: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readCookie(cookieName: string): Promise<string | undefined> {
|
|
72
|
+
try {
|
|
73
|
+
const { getCookies } = await import("@tanstack/react-start/server");
|
|
74
|
+
const cookies = getCookies();
|
|
75
|
+
return cookies?.[cookieName];
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default async function salesforceListCartLoader(
|
|
82
|
+
props: SalesforceListCartProps,
|
|
83
|
+
_req?: Request,
|
|
84
|
+
): Promise<SalesforceListCartResult | null> {
|
|
85
|
+
const { items, baseUrl, dataset, campaignId, cookieName, currencyCode, title, propertyMapper } =
|
|
86
|
+
props;
|
|
87
|
+
|
|
88
|
+
if (!items?.length) return null;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const rawCookie = await readCookie(cookieName);
|
|
92
|
+
const userData = parseUserCookie(rawCookie);
|
|
93
|
+
|
|
94
|
+
const client = createHttpClient({
|
|
95
|
+
base: baseUrl,
|
|
96
|
+
headers: { "x-requested-with": "XMLHttpRequest" },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const requestBody: PersonalizationBody = {
|
|
100
|
+
source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
|
|
101
|
+
interaction: {
|
|
102
|
+
name: "Replace Cart",
|
|
103
|
+
lineItems: items.map(({ sku, qty, price }) => ({
|
|
104
|
+
catalogObjectType: "Product",
|
|
105
|
+
catalogObjectId: sku,
|
|
106
|
+
quantity: qty,
|
|
107
|
+
price,
|
|
108
|
+
})),
|
|
109
|
+
},
|
|
110
|
+
user: userData,
|
|
111
|
+
flags: { nonInteractive: true, doNotTrack: false },
|
|
112
|
+
pageView: false,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const response = (await client["POST /api2/event/:dataset"](
|
|
116
|
+
{ dataset },
|
|
117
|
+
{ body: requestBody },
|
|
118
|
+
).then((res: { json: () => Promise<PersonalizationResponse> }) =>
|
|
119
|
+
res.json(),
|
|
120
|
+
)) as PersonalizationResponse;
|
|
121
|
+
|
|
122
|
+
const payload =
|
|
123
|
+
response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
|
|
124
|
+
?.payload ?? response.campaignResponses?.[0]?.payload;
|
|
125
|
+
|
|
126
|
+
if (!payload?.products?.length) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const transform = createProductTransformer({ propertyMapper });
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
"@type": "ProductList",
|
|
134
|
+
list: payload.products.map((product: SalesforceProduct) =>
|
|
135
|
+
transform({
|
|
136
|
+
product,
|
|
137
|
+
options: { currencyCode: currencyCode ?? product.currency },
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
additionalData: {
|
|
141
|
+
title: payload.headerText || title,
|
|
142
|
+
campaignId,
|
|
143
|
+
experienceId: payload.experience,
|
|
144
|
+
userGroup: payload.userGroup,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error("[salesforce/products/listCart] failed:", (err as Error)?.message);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salesforce Marketing Cloud Personalization — recommendations loader.
|
|
3
|
+
*
|
|
4
|
+
* Same wire format as `list.ts`, but the campaign Evergage runs is the
|
|
5
|
+
* "related products" one and the request body includes the
|
|
6
|
+
* `viewedProductId` attribute so Evergage can use the in-context PDP
|
|
7
|
+
* to seed its recommendation model.
|
|
8
|
+
*
|
|
9
|
+
* Used on PDP / PDC pages; the upstream site passes the resolved
|
|
10
|
+
* `ProductDetailsPage` so we can read `product.sku`. When no product
|
|
11
|
+
* context is available the loader still sends the campaign request —
|
|
12
|
+
* Evergage will return its default fallback list.
|
|
13
|
+
*/
|
|
14
|
+
import type { Product, ProductDetailsPage } from "@decocms/apps-commerce/types";
|
|
15
|
+
import type { CampaignResponse, PersonalizationResponse, SalesforceProduct } from "../../types";
|
|
16
|
+
import { createHttpClient } from "../../utils/httpClient";
|
|
17
|
+
import { parseUserCookie } from "../../utils/parseUserCookie";
|
|
18
|
+
import { createProductTransformer, type PropertyMapper } from "../../utils/transform";
|
|
19
|
+
|
|
20
|
+
export interface SalesforceListRecommendedProps {
|
|
21
|
+
/** @title Product Id (resolved PDP) */
|
|
22
|
+
productId: ProductDetailsPage | null;
|
|
23
|
+
/** @title Personalization Base URL */
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
/** @title Personalization Dataset */
|
|
26
|
+
dataset: string;
|
|
27
|
+
/** @title Campaign Id */
|
|
28
|
+
campaignId: string;
|
|
29
|
+
/** @title Cookie Name */
|
|
30
|
+
cookieName: string;
|
|
31
|
+
/** @title Currency Code (ISO 4217) */
|
|
32
|
+
currencyCode?: string;
|
|
33
|
+
/** Custom property mapper. */
|
|
34
|
+
propertyMapper?: PropertyMapper;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SalesforceListRecommendedResult {
|
|
38
|
+
"@type": "ProductList";
|
|
39
|
+
list: Product[];
|
|
40
|
+
additionalData: {
|
|
41
|
+
title?: string;
|
|
42
|
+
campaignId: string;
|
|
43
|
+
experienceId?: string;
|
|
44
|
+
userGroup?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readCookie(cookieName: string): Promise<string | undefined> {
|
|
49
|
+
try {
|
|
50
|
+
const { getCookies } = await import("@tanstack/react-start/server");
|
|
51
|
+
const cookies = getCookies();
|
|
52
|
+
return cookies?.[cookieName];
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function salesforceListRecommendedLoader(
|
|
59
|
+
props: SalesforceListRecommendedProps,
|
|
60
|
+
_req?: Request,
|
|
61
|
+
): Promise<SalesforceListRecommendedResult | null> {
|
|
62
|
+
const { baseUrl, dataset, productId, cookieName, campaignId, currencyCode, propertyMapper } =
|
|
63
|
+
props;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const rawCookie = await readCookie(cookieName);
|
|
67
|
+
const userData = parseUserCookie(rawCookie);
|
|
68
|
+
|
|
69
|
+
const client = createHttpClient({
|
|
70
|
+
base: baseUrl,
|
|
71
|
+
headers: { "x-requested-with": "XMLHttpRequest" },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const requestBody = {
|
|
75
|
+
source: { channel: "WebServer", url: `mcp_campaign=${campaignId}` },
|
|
76
|
+
interaction: { name: "Personalization Campaigns" },
|
|
77
|
+
user: {
|
|
78
|
+
...userData,
|
|
79
|
+
attributes: { viewedProductId: productId?.product?.sku ?? "" },
|
|
80
|
+
},
|
|
81
|
+
flags: { nonInteractive: true, doNotTrack: false },
|
|
82
|
+
pageView: false,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const response = (await client["POST /api2/event/:dataset"](
|
|
86
|
+
{ dataset },
|
|
87
|
+
{ body: requestBody },
|
|
88
|
+
).then((res: { json: () => Promise<PersonalizationResponse> }) =>
|
|
89
|
+
res.json(),
|
|
90
|
+
)) as PersonalizationResponse;
|
|
91
|
+
|
|
92
|
+
const payload =
|
|
93
|
+
response.campaignResponses?.find((item: CampaignResponse) => item.campaignId === campaignId)
|
|
94
|
+
?.payload ?? response.campaignResponses?.[0]?.payload;
|
|
95
|
+
|
|
96
|
+
if (!payload?.products?.length) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const transform = createProductTransformer({ propertyMapper });
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
"@type": "ProductList",
|
|
104
|
+
list: payload.products.map((product: SalesforceProduct) =>
|
|
105
|
+
transform({
|
|
106
|
+
product,
|
|
107
|
+
options: { currencyCode: currencyCode ?? product.currency },
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
additionalData: {
|
|
111
|
+
title: payload.headerText,
|
|
112
|
+
campaignId,
|
|
113
|
+
experienceId: payload.experience,
|
|
114
|
+
userGroup: payload.userGroup,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error("[salesforce/products/listRecomended] failed:", (err as Error)?.message);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Salesforce Marketing Cloud Personalization (Evergage) — request/response
|
|
3
|
+
* shapes for the campaign personalization API.
|
|
4
|
+
*
|
|
5
|
+
* The Evergage product schema is configurable per dataset (each customer
|
|
6
|
+
* chooses which fields to expose), so `SalesforceProduct` keeps a minimal
|
|
7
|
+
* required surface and accepts arbitrary extras via index signature.
|
|
8
|
+
* Site-level transformers can downcast to their own product shape to
|
|
9
|
+
* read store-specific custom fields (e.g. `marca`, `linha`, brand-tags).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface SalesforceProduct {
|
|
13
|
+
/** Internal Evergage id (string token, often a SKU or catalog id). */
|
|
14
|
+
id: string;
|
|
15
|
+
/** Display name. */
|
|
16
|
+
name: string;
|
|
17
|
+
/** Base price (list). */
|
|
18
|
+
price: number;
|
|
19
|
+
/** Discounted price; falls back to `price` when no promotion is active. */
|
|
20
|
+
salePrice?: number;
|
|
21
|
+
/** Stock count from Evergage's catalog feed. */
|
|
22
|
+
inventoryCount: number;
|
|
23
|
+
/** Pre-built absolute URLs of product images. */
|
|
24
|
+
imageUrls: string[];
|
|
25
|
+
/** Canonical product URL (full origin + path). */
|
|
26
|
+
url: string;
|
|
27
|
+
/** ISO 4217 currency code (e.g. "BRL"). */
|
|
28
|
+
currency: string;
|
|
29
|
+
/** Common Evergage column — short product description. */
|
|
30
|
+
description?: string;
|
|
31
|
+
/** Optional cross-system identifier (e.g. Magento `entity_id`). */
|
|
32
|
+
idMagento?: string;
|
|
33
|
+
/** Item type tag (e.g. "configurable", "simple"). */
|
|
34
|
+
itemType?: string;
|
|
35
|
+
/** Generic category trail (Evergage stores these as arrays). */
|
|
36
|
+
categories?: string[];
|
|
37
|
+
/** Site-specific extras — Evergage exposes whatever the dataset schema
|
|
38
|
+
* defines (e.g. `Marca`, `Volume`, `Linha`, `freeShipping`). The
|
|
39
|
+
* product transformer can read these via the attributeMapper hook. */
|
|
40
|
+
[customField: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Single line item in a cart-interaction request body. Evergage uses
|
|
45
|
+
* these to seed cart-aware recommendations ("people who bought X also
|
|
46
|
+
* bought Y") and abandoned-cart campaigns.
|
|
47
|
+
*/
|
|
48
|
+
export interface PersonalizationLineItem {
|
|
49
|
+
catalogObjectType: string;
|
|
50
|
+
catalogObjectId: string;
|
|
51
|
+
quantity: number;
|
|
52
|
+
price: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Body sent to `POST {baseUrl}/api2/event/:dataset`. The Evergage API is
|
|
57
|
+
* "interaction"-based — every request describes an interaction the user
|
|
58
|
+
* had, and the response contains the campaigns triggered by that
|
|
59
|
+
* interaction (recommendations, popups, etc.).
|
|
60
|
+
*
|
|
61
|
+
* `interaction.lineItems` is only used by cart-aware campaigns; PDP /
|
|
62
|
+
* homepage requests omit it. `user.attributes` carries page-context
|
|
63
|
+
* hints (e.g. `viewedProductId` for related-product campaigns).
|
|
64
|
+
*/
|
|
65
|
+
export interface PersonalizationBody {
|
|
66
|
+
source: {
|
|
67
|
+
channel: string;
|
|
68
|
+
url: string;
|
|
69
|
+
};
|
|
70
|
+
interaction: {
|
|
71
|
+
name: string;
|
|
72
|
+
lineItems?: PersonalizationLineItem[];
|
|
73
|
+
};
|
|
74
|
+
user: {
|
|
75
|
+
anonymousId?: string;
|
|
76
|
+
encryptedId?: string;
|
|
77
|
+
attributes?: Record<string, unknown>;
|
|
78
|
+
};
|
|
79
|
+
flags: {
|
|
80
|
+
nonInteractive: boolean;
|
|
81
|
+
doNotTrack: boolean;
|
|
82
|
+
};
|
|
83
|
+
pageView: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CampaignResponse {
|
|
87
|
+
campaignId: string;
|
|
88
|
+
payload: {
|
|
89
|
+
experience?: string;
|
|
90
|
+
headerText?: string;
|
|
91
|
+
products?: SalesforceProduct[];
|
|
92
|
+
userGroup?: string;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PersonalizationResponse {
|
|
97
|
+
campaignResponses?: CampaignResponse[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Cookie shape Evergage drops on the browser (`puid` = persistent user
|
|
102
|
+
* id after sign-in, `uuid` = anonymous device id). The site loaders
|
|
103
|
+
* read this cookie to send the right user identifier on each request.
|
|
104
|
+
*/
|
|
105
|
+
export interface ParsedUserCookie {
|
|
106
|
+
encryptedId?: string;
|
|
107
|
+
anonymousId?: string;
|
|
108
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed HTTP client compatible with deco-cx/apps' `createHttpClient`
|
|
3
|
+
* signature. Accepts both the simple `.get(path)` / `.post(path, body)`
|
|
4
|
+
* shape and the indexed-route shape used by legacy Deno loaders:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const client = createHttpClient({ base: "https://api.example.com" });
|
|
8
|
+
* await client["POST /api2/event/:dataset"]({ dataset: "prod" }, { body });
|
|
9
|
+
* await client.get("/healthcheck");
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Runtime-agnostic: only requires the global `fetch`. Works on
|
|
13
|
+
* Cloudflare Workers, Bun, Deno, and modern Node — no `node:http`
|
|
14
|
+
* dependency. Returns `{ json, ok, status, headers }` from indexed
|
|
15
|
+
* routes (mirrors the legacy `apps/utils/http.ts` shape so existing
|
|
16
|
+
* loaders that call `.then(res => res.json())` keep working).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface HttpClientOptions {
|
|
20
|
+
base: string;
|
|
21
|
+
headers?: Record<string, string> | Headers;
|
|
22
|
+
fetcher?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface IndexedRouteResponse {
|
|
26
|
+
json: <T = unknown>() => Promise<T>;
|
|
27
|
+
ok: boolean;
|
|
28
|
+
status: number;
|
|
29
|
+
headers: Headers;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createHttpClient<_Routes = unknown>(options: HttpClientOptions) {
|
|
33
|
+
const base = options.base.replace(/\/$/, "");
|
|
34
|
+
const fetchImpl = options.fetcher ?? fetch;
|
|
35
|
+
const defaultHeaders: Record<string, string> =
|
|
36
|
+
options.headers instanceof Headers
|
|
37
|
+
? Object.fromEntries(options.headers.entries())
|
|
38
|
+
: (options.headers ?? {});
|
|
39
|
+
|
|
40
|
+
const handler: ProxyHandler<Record<string, unknown>> = {
|
|
41
|
+
get(_target, prop) {
|
|
42
|
+
if (prop === "get") {
|
|
43
|
+
return async <R = unknown>(path: string, init?: RequestInit): Promise<R> => {
|
|
44
|
+
const res = await fetchImpl(`${base}${path}`, {
|
|
45
|
+
...init,
|
|
46
|
+
headers: {
|
|
47
|
+
...defaultHeaders,
|
|
48
|
+
...((init?.headers as Record<string, string>) ?? {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return res.json() as Promise<R>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (prop === "post") {
|
|
55
|
+
return async <R = unknown>(path: string, body: unknown, init?: RequestInit): Promise<R> => {
|
|
56
|
+
const res = await fetchImpl(`${base}${path}`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
...init,
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
...defaultHeaders,
|
|
62
|
+
...((init?.headers as Record<string, string>) ?? {}),
|
|
63
|
+
},
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
});
|
|
66
|
+
return res.json() as Promise<R>;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (typeof prop === "string" && /^(GET|POST|PUT|PATCH|DELETE)\s+/.test(prop)) {
|
|
70
|
+
const spaceIdx = prop.indexOf(" ");
|
|
71
|
+
const method = prop.slice(0, spaceIdx);
|
|
72
|
+
let apiPath = prop.slice(spaceIdx + 1);
|
|
73
|
+
|
|
74
|
+
return async (
|
|
75
|
+
params: Record<string, unknown> = {},
|
|
76
|
+
init?: RequestInit & { body?: unknown },
|
|
77
|
+
): Promise<IndexedRouteResponse> => {
|
|
78
|
+
const cleanParams = { ...params };
|
|
79
|
+
|
|
80
|
+
// `:name` path placeholders — replaced with the matching
|
|
81
|
+
// param value, then removed from the body/query object.
|
|
82
|
+
for (const [key, value] of Object.entries(cleanParams)) {
|
|
83
|
+
const placeholder = `:${key}`;
|
|
84
|
+
if (apiPath.includes(placeholder) && value != null) {
|
|
85
|
+
apiPath = apiPath.replace(placeholder, encodeURIComponent(String(value)));
|
|
86
|
+
delete cleanParams[key];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Legacy `*name` placeholders (Deno-era convention).
|
|
91
|
+
const starMatch = apiPath.match(/\*(\w+)/);
|
|
92
|
+
if (starMatch) {
|
|
93
|
+
const paramName = starMatch[1];
|
|
94
|
+
if (cleanParams[paramName] != null) {
|
|
95
|
+
apiPath = apiPath.replace(`*${paramName}`, String(cleanParams[paramName]));
|
|
96
|
+
delete cleanParams[paramName];
|
|
97
|
+
} else {
|
|
98
|
+
apiPath = apiPath.replace(/\/\*\w+/, "");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let url = `${base}${apiPath}`;
|
|
103
|
+
|
|
104
|
+
if (method === "GET") {
|
|
105
|
+
const sp = new URLSearchParams();
|
|
106
|
+
for (const [k, v] of Object.entries(cleanParams)) {
|
|
107
|
+
if (v !== undefined && v !== null) sp.set(k, String(v));
|
|
108
|
+
}
|
|
109
|
+
const qs = sp.toString();
|
|
110
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Indexed-route callers can either embed the body in the
|
|
114
|
+
// remaining params (legacy style) OR pass `{ body }` in the
|
|
115
|
+
// second arg (newer call sites). The `body` key in init
|
|
116
|
+
// takes precedence when present.
|
|
117
|
+
const explicitBody = init && "body" in init ? init.body : undefined;
|
|
118
|
+
const fetchBody =
|
|
119
|
+
method === "GET"
|
|
120
|
+
? undefined
|
|
121
|
+
: explicitBody !== undefined
|
|
122
|
+
? JSON.stringify(explicitBody)
|
|
123
|
+
: Object.keys(cleanParams).length > 0
|
|
124
|
+
? JSON.stringify(cleanParams)
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
const fetchInit: RequestInit = {
|
|
128
|
+
method,
|
|
129
|
+
...(init ?? {}),
|
|
130
|
+
headers: {
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
...defaultHeaders,
|
|
133
|
+
...(init?.headers instanceof Headers
|
|
134
|
+
? Object.fromEntries(init.headers.entries())
|
|
135
|
+
: ((init?.headers as Record<string, string>) ?? {})),
|
|
136
|
+
},
|
|
137
|
+
...(fetchBody !== undefined ? { body: fetchBody } : {}),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const res = await fetchImpl(url, fetchInit);
|
|
141
|
+
return {
|
|
142
|
+
json: <T = unknown>() => res.json() as Promise<T>,
|
|
143
|
+
ok: res.ok,
|
|
144
|
+
status: res.status,
|
|
145
|
+
headers: res.headers,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return new Proxy({} as Record<string, unknown>, handler) as Record<string, any>;
|
|
154
|
+
}
|