B2B

Using Tableau as our “client”, the following document describes and presents code for an example implementation. The sample code in this document is not exhaustive and maps only key areas of the site that are valuable for demonstration purposes. The primary focus is depicting how to approach designing a Catalog for B2B websites. While each implementation is unique, this sitemap contends with many issues, particularly mapping B2B sites and websites without purchasable products in general.

The sitemap code examples in this article are only for demonstration purposes. Avoid copying sample code into your Sitemap Editor as it is built around example scenarios that can differ from your implementation’s goals and use cases.

Important

Client Requirements 

This section outlines the functionality that our imaginary client requires from their implementation. Since each client or customer always has a unique set of requirements and use cases in mind for Marketing Cloud Personalization, experience is the real teacher when it comes to knowing what questions to ask clients to help them establish their goals and requirements for their use of Personalization.

Primary Goal 

  • Understand the content a user is most interested in based on their prior interactions with the site.

Home page type 

  • Recommend Blogs similar to recently viewed Blogs, fallback to promote trending Blogs.

Solutions, Learning, and Product Landing page types 

  • Recommend Articles of the same Category (solutions, learn, or products) as the currently viewed landing page.
  • Personalize banner based on past interest in Articles under the currently viewed Category

Solutions, Learning, and Product page types 

  • Capture Industry, JobRole, PageBundle, and Department values as related catalog objects.

Product page type 

  • Capture Keyword values as a related catalog object.
  • Recommend related product Articles by Keyword.
  • Distinguish between Add-On and Software products in the Catalog.

Blog Landing page type 

  • Recommend trending Blogs.

Blog page type 

  • Recommend related Blog posts based on category.

Discovery 

  • Since it is the only identity consistently available on the web and used as a username when users log in to the site, emailAddress is used as the default Web SDK identity attribute used for this implementation. The site already uses email addresses to identify its users, so we are configuring Personalization to piggyback on that identity attribute.

    Aside: Since an email address is Personally Identifiable Information (PII), make sure you have the appropriate approval from within your organization before using it as a Personalization Identity. Additionally, using emails captured from web forms, other than a login form, as an Identity could potentially cause privacy issues where more than one user is tied to a Personalization user. In the following code, email is only being captured from the login form when a user successfully logs in. The code capturing user id is designed to work on Tableau Public as this portion of the Tableau site is accessible by everyone with a free public account. For more information on Identity, read Identity System Setup for the Web SDK.

  • While this site does promote products, there is no ability to purchase products or attribute revenue to them from other channels, matching with user activity from the web. Since we do not need the special capabilities of the Product item type for this implementation, we are instead using the Article item type, using Category to tell types of Article items apart.

    Aside: If the website had a more robust, hierarchical category system in which it could be possible to share Category information between the Article types, we would instead configure a catalog object specifically to house the type of Article, such as ArticleType. This way, if solutions and products shared Category data, they would be relatable to each other, instead of belonging to siloed Category hierarchies, each starting with their respective Article type (solutions, products, and learning).

  • There is a categorization system already natively built into the site’s blog section, so our code scrapes the categories already present in the dataLayer as Category in the Personalization Catalog. Collecting Category data for the Blogs help build user affinity based on those categories. These categories are used to also power the customer’s use case to recommend related Blogs on blog pages based on the currently viewed Category.
  • In addition to Category data, the dataLayer contains other useful information, such as Department and Job Role. The values for Department and Job Role are collected as relatedCatalogObjects to tie them to each of the collected Article types. As these values are collected, they are used to build a user’s affinity towards them, which can then be used for segmentation and potentially for recommending Articles of any Category based on one of these related catalog objects, instead of only recommending Articles of one type at a time.
  • To distinguish between Add-On and Software products in the Catalog, as stated in the “Client Requirements”, we are mapping a related catalog object called ProductType on the “product” page type. Remember, we are using the Article item type to house Catalog data for the product, solutions, and learning page types. As a result, our ProductType catalog object is attached to the Article Catalog item type when configuring the Catalog, even though it is mapped only on one page type containing Article Catalog data. The ProductType related catalog object helps to more granularly distinguish which type of product Article is being viewed by the user and create highly targeted recipes based on that activity, if desired.

The following examples assume that emailAddress has been configured as the default Web SDK Identity.

Note

Example Sitemap in the SalesforceInteractions Namespace 

1SalesforceInteractions.init({
2  cookieDomain: "tableau.com",
3  consents: [
4    {
5      purpose: SalesforceInteractions.mcis.ConsentPurpose.Personalization,
6      provider: "Example Consent Manager",
7      status: SalesforceInteractions.ConsentStatus.OptIn,
8    },
9  ],
10}).then(() => {
11  const findInDataLayer = (targetAttribute) => {
12    if (!window.dataLayer) {
13      return;
14    }
15    for (let i = 0; i < window.dataLayer.length; i++) {
16      const result = SalesforceInteractions.mcis.getValueFromNestedObject(
17        "window.dataLayer[" + i + "]",
18      );
19      if (result && result[targetAttribute]) {
20        return result;
21      }
22    }
23    return;
24  };
25
26  // We set a value for this variable only once per page load and then reference it several times for the data found in the dataLayer needed for each page.
27  let pageDetails;
28  const setPageDetailsFromDataLayer = () => {
29    pageDetails = pageDetails || findInDataLayer("entityId");
30  };
31
32  /*
33    Here we will first check session storage for the islogin item containing the user's email that is associated with
34    their account. This value is set in the global event listener in the site config when a submission event occurs. On this
35    website, both successful and invalid form submissions emit a submission event, the page also is quickly reloaded
36    after a successful login.
37    In this unusual situation, we will first save the submitted data in session storage. Then when the page loads,
38    we check for the islogin value in session storage and then look for a DOM element which indicates a successful
39    login before retrieving the stored user email and sending our login event.
40    */
41  let loginEvent = sessionStorage.getItem("islogin");
42  // if there is a stored login event...
43  if (loginEvent) {
44    // wait for the logged in user dropdown to appear in the nav, signifying a successful login
45    SalesforceInteractions.DisplayUtils.pageElementLoaded(
46      "#block-public-sitewide-ui-author-profile-dropdown",
47    ).then(() => {
48      // remove the stored email from session storage
49      sessionStorage.removeItem("islogin");
50      // construct and send the Login Success event to Personalization
51      SalesforceInteractions.sendEvent({
52        interaction: { name: "Login Success" },
53        user: {
54          /*
55                        The users email address provided in the login form is sent to Personalization as user.identities.emailAddress because
56                        in this example, emailAddress is configured to be the default identity for the web channel.
57                    */
58          identities: {
59            emailAddress: loginEvent,
60          },
61        },
62      });
63    });
64  }
65
66  const config = {
67    global: {
68      contentZones: [
69        { name: "global_popup" },
70        { name: "global_infobar" },
71        { name: "global_exit_intent" },
72        /*
73                Since the website does not have consistent selectors and structure within the Article pages,
74                this content zone will be used to add recs to the bottom of Article pages.
75                */
76        { name: "global_footer", selector: "footer.global-footer" },
77      ],
78      listeners: [
79        /*
80                Here we are listening for all submission events that happen within this document. This pattern
81                can be used to create generic form submission event handlers, or even just to consolidate them
82                to one place in the sitemap code.
83                */
84        SalesforceInteractions.listener("submit", "body", (event) => {
85          // Check the id of the event target to check for the login form we want to scrape data from
86          if (event.target.id === "login-form") {
87            // loop through login form fields...
88            for (i = 0; i < event.target.length; i++) {
89              // Find the email field by id within the event object.
90              if (event.target[i].id === "login-email") {
91                // save user email in session storage
92                sessionStorage.setItem("islogin", event.target[i].value);
93              }
94            }
95          }
96        }),
97      ],
98    },
99    pageTypeDefault: {
100      name: "default",
101      interaction: {
102        name: "Default Page",
103      },
104    },
105    pageTypes: [
106      {
107        name: "home",
108        isMatch: () => /^\/$/.test(window.location.pathname),
109        interaction: {
110          name: "Homepage",
111        },
112        contentZones: [
113          { name: "home_recs_1", selector: "section.datalocation-audience-segment-links" },
114          { name: "home_recs_2", selector: "section.datalocation-customer-stories" },
115        ],
116      },
117      {
118        name: "products_landing",
119        isMatch: () => /^\/products\/?$/.test(window.location.pathname),
120        interaction: {
121          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
122          catalogObject: {
123            /*
124                        Remember, Catalog IDs are case sensitive. Transforming collected values as all upper case or all lower
125                        case is a common method for ensuring consistency throughout the site when creating Catalog IDs with the
126                        sitemap which do not need to correspond to data in external systems.
127                        */
128            type: "Category",
129            id: () =>
130              SalesforceInteractions.mcis
131                .getLastPathComponentWithoutExtension(window.location.pathname)
132                .toLowerCase(),
133            attributes: {
134              url: SalesforceInteractions.resolvers.fromCanonical(),
135              name: SalesforceInteractions.resolvers.fromMeta("og:title"),
136            },
137          },
138        },
139        contentZones: [{ name: "products_landing_hero_banner", selector: "#hero" }],
140      },
141      {
142        name: "solutions_landing",
143        isMatch: () =>
144          /^\/solutions\/?(customers|industries|departments|technologies)?$/.test(
145            window.location.pathname,
146          ),
147        interaction: {
148          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
149          catalogObject: {
150            /*
151                        The last parameter of each resolver function accepts a function which returns whatever the desired final value is.
152                        This is very useful when you need to transform or sanitize a scraped value before sending it to Interaction studio.
153
154                        You can learn more about resolver functions here: https://developer.salesforce.com/docs/marketing/personalization/guide/sitemap-implementation.html#resolvers
155
156                        Below, the pathname portion of the URL is transformed into a hierarchal Category id.
157                        */
158            type: "Category",
159            id: SalesforceInteractions.resolvers.fromWindow("location.pathname", (path) =>
160              path.split("/").slice(1).join("|").toLowerCase(),
161            ),
162            attributes: {
163              url: SalesforceInteractions.resolvers.fromCanonical(),
164              name: SalesforceInteractions.resolvers.fromMeta("og:title"),
165            },
166          },
167        },
168        contentZones: [{ name: "solutions_landing_hero_banner", selector: "#hero" }],
169      },
170      {
171        name: "learning_landing",
172        isMatch: () => /^\/learn\/?$/.test(window.location.pathname),
173        interaction: {
174          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
175          catalogObject: {
176            type: "Category",
177            id: () =>
178              SalesforceInteractions.mcis
179                .getLastPathComponentWithoutExtension(window.location.pathname)
180                .toLowerCase(),
181            attributes: {
182              url: SalesforceInteractions.resolvers.fromCanonical(),
183              name: SalesforceInteractions.resolvers.fromMeta("og:title"),
184            },
185          },
186        },
187        contentZones: [
188          {
189            name: "learning_landing_gray_recs",
190            selector: ".entity-paragraphs-item.paragraph--type--cross-reference",
191          },
192          { name: "learning_landing_hero_banner", selector: "#hero" },
193        ],
194      },
195      {
196        name: "blog_landing",
197        isMatch: () => /^\/about\/blog\/?$/.test(window.location.pathname),
198        interaction: {
199          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
200          catalogObject: {
201            type: "Category",
202            id: () =>
203              SalesforceInteractions.mcis
204                .getLastPathComponentWithoutExtension(window.location.pathname)
205                .toLowerCase(),
206            attributes: {
207              url: SalesforceInteractions.resolvers.fromCanonical(),
208              name: SalesforceInteractions.resolvers.fromMeta("og:title"),
209            },
210          },
211        },
212        contentZones: [
213          { name: "blog_landing_card_wall", selector: ".card-wall" },
214          { name: "blog_landing_hero_banner", selector: "#hero" },
215        ],
216      },
217      {
218        name: "product",
219        action: "Product",
220        isMatch: () => {
221          if (/^\/products\//.test(window.location.pathname)) {
222            /*
223                        We only want to call setPageDetailsFromDataLayer() when pages which rely on scraping information from the dataLayer are actually matched.
224                        Adding a conditional statement prevents this function from being called on every page on the site as Personalization resolves every
225                        isMatch function to see which page type matches the current one.
226                        */
227            setPageDetailsFromDataLayer();
228            return true;
229          }
230          return false;
231        },
232        interaction: {
233          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
234          catalogObject: {
235            /*
236                        Remember, while this site does promote products, there is no ability to purchase products or attribute
237                        revenue to them from other channels which would match with user activity from the web.
238                        Since the special capabilities of the Product item type will not be needed for this implementation,
239                        we will instead use the Article item type, utilizing Category to tell types of Article apart.
240                        */
241            type: "Article",
242            /*
243                        We are using SalesforceInteractions.mcis.getValueFromNestedObject() to more easily reference data from the pageDetails
244                        object after the value is set when the page matches.
245                        */
246            id: () => SalesforceInteractions.mcis.getValueFromNestedObject("entityId", pageDetails),
247            attributes: {
248              name: () =>
249                SalesforceInteractions.mcis.getValueFromNestedObject("entityLabel", pageDetails),
250              url: SalesforceInteractions.resolvers.fromCanonical(),
251              imageUrl: SalesforceInteractions.resolvers.fromSelectorAttribute(
252                "div.feature-highlight__image img",
253                "src",
254              ),
255            },
256            relatedCatalogObjects: {
257              Category: SalesforceInteractions.resolvers.fromWindow("location.pathname", (path) => {
258                const categories = path.split("/").slice(1);
259                categories.pop();
260                if (categories.length === 0) {
261                  const dataLayerCategories = SalesforceInteractions.mcis.getValueFromNestedObject(
262                    "page.category1",
263                    pageDetails,
264                  );
265                  return dataLayerCategories ? [dataLayerCategories.toLowerCase()] : [];
266                }
267                return [categories.join("|").toLowerCase()];
268              }),
269              ProductType: () =>
270                window.location.pathname.indexOf("add-ons") > -1 ? ["Add-On"] : ["Software"],
271              Industry: () =>
272                SalesforceInteractions.mcis.getValueFromNestedObject(
273                  "dataModelFields.field_dmo_industries",
274                  pageDetails,
275                ) || null,
276              Department: () =>
277                SalesforceInteractions.mcis.getValueFromNestedObject(
278                  "dataModelFields.field_departments",
279                  pageDetails,
280                ) || null,
281              JobRole: () =>
282                SalesforceInteractions.mcis.getValueFromNestedObject(
283                  "dataModelFields.taxonomy_vocabulary_23",
284                  pageDetails,
285                ) || null,
286              PageBundle: () => {
287                const pageBundle = SalesforceInteractions.mcis.getValueFromNestedObject(
288                  "entityBundleNice",
289                  pageDetails,
290                );
291                return pageBundle ? [pageBundle] : null;
292              },
293              Keyword: SalesforceInteractions.resolvers.fromMeta("keywords", (ele) => {
294                return ele ? ele.split(/\,\s*/) : null;
295              }),
296            },
297          },
298        },
299        listeners: [
300          SalesforceInteractions.listener(
301            "submit",
302            "#webform-submission-email-embeddable-1-add-form",
303            (event) => {
304              SalesforceInteractions.sendEvent({
305                interaction: { name: "Free Trial Download" },
306                /*
307                            A content zone is provided in this event in order to allow a campaign targeting the "global_infobar"
308                            content zone to be returned in the response sent back from Personalization with this request.
309                            */
310                source: {
311                  contentZones: ["global_infobar"],
312                },
313              });
314            },
315          ),
316        ],
317      },
318      {
319        name: "solutions",
320        isMatch: () => {
321          if (
322            /\/solutions\/(?!customers|industries|departments|technologies)/.test(
323              window.location.pathname,
324            )
325          ) {
326            setPageDetailsFromDataLayer();
327            return true;
328          }
329          return false;
330        },
331        interaction: {
332          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
333          catalogObject: {
334            type: "Article",
335            id: () => SalesforceInteractions.mcis.getValueFromNestedObject("entityId", pageDetails),
336            attributes: {
337              name: () =>
338                SalesforceInteractions.mcis.getValueFromNestedObject("entityLabel", pageDetails),
339              url: SalesforceInteractions.resolvers.fromCanonical(),
340              imageUrl: SalesforceInteractions.resolvers.fromSelectorAttribute(
341                "div.feature-highlight__image img",
342                "src",
343              ),
344            },
345            relatedCatalogObjects: {
346              Category: SalesforceInteractions.resolvers.fromWindow("location.pathname", (path) => {
347                const categories = path.split("/").slice(1);
348                categories.pop();
349                return [categories.join("|").toLowerCase()];
350              }),
351              Industry: () =>
352                SalesforceInteractions.mcis.getValueFromNestedObject(
353                  "dataModelFields.field_dmo_industries",
354                  pageDetails,
355                ) || null,
356              Department: () =>
357                SalesforceInteractions.mcis.getValueFromNestedObject(
358                  "dataModelFields.field_departments",
359                  pageDetails,
360                ) || null,
361              JobRole: () =>
362                SalesforceInteractions.mcis.getValueFromNestedObject(
363                  "dataModelFields.taxonomy_vocabulary_23",
364                  pageDetails,
365                ) || null,
366              PageBundle: () => {
367                const pageBundle = SalesforceInteractions.mcis.getValueFromNestedObject(
368                  "entityBundleNice",
369                  pageDetails,
370                );
371                return pageBundle ? [pageBundle] : null;
372              },
373            },
374          },
375        },
376      },
377      {
378        name: "learning",
379        isMatch: () => {
380          if (/^\/learn\//.test(window.location.pathname)) {
381            setPageDetailsFromDataLayer();
382            return true;
383          }
384          return false;
385        },
386        interaction: {
387          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
388          catalogObject: {
389            type: "Article",
390            id: () => SalesforceInteractions.mcis.getValueFromNestedObject("entityId", pageDetails),
391            attributes: {
392              name: () =>
393                SalesforceInteractions.mcis.getValueFromNestedObject("entityLabel", pageDetails),
394              url: SalesforceInteractions.resolvers.fromCanonical(),
395              imageUrl: SalesforceInteractions.resolvers.fromSelectorAttribute(
396                "div.feature-highlight__image img",
397                "src",
398              ),
399            },
400            relatedCatalogObjects: {
401              Category: SalesforceInteractions.resolvers.fromWindow("location.pathname", (path) => {
402                const categories = path.split("/").slice(1);
403                categories.pop();
404                return [categories.join("|").toLowerCase()];
405              }),
406              Industry: () =>
407                SalesforceInteractions.mcis.getValueFromNestedObject(
408                  "dataModelFields.field_dmo_industries",
409                  pageDetails,
410                ) || null,
411              Department: () =>
412                SalesforceInteractions.mcis.getValueFromNestedObject(
413                  "dataModelFields.field_departments",
414                  pageDetails,
415                ) || null,
416              JobRole: () =>
417                SalesforceInteractions.mcis.getValueFromNestedObject(
418                  "dataModelFields.taxonomy_vocabulary_23",
419                  pageDetails,
420                ) || null,
421              PageBundle: () => {
422                const pageBundle = SalesforceInteractions.mcis.getValueFromNestedObject(
423                  "entityBundleNice",
424                  pageDetails,
425                );
426                return pageBundle ? [pageBundle] : null;
427              },
428            },
429          },
430        },
431      },
432      {
433        name: "blog",
434        isMatch: () => {
435          if (/\/about\/blog\/\d+\/\d+\/.+/.test(window.location.pathname)) {
436            setPageDetailsFromDataLayer();
437            return true;
438          }
439          return false;
440        },
441        interaction: {
442          name: SalesforceInteractions.CatalogObjectInteractionName.ViewCatalogObject,
443          catalogObject: {
444            type: "Blog",
445            id: () => SalesforceInteractions.mcis.getValueFromNestedObject("entityId", pageDetails),
446            attributes: {
447              name: () =>
448                SalesforceInteractions.mcis.getValueFromNestedObject("entityLabel", pageDetails),
449              url: SalesforceInteractions.resolvers.fromCanonical(),
450              imageUrl: SalesforceInteractions.resolvers.fromMeta("og:image"),
451            },
452            relatedCatalogObjects: {
453              Category: () => [
454                SalesforceInteractions.mcis.getValueFromNestedObject(
455                  "flatTaxonomy.blog_categories",
456                  pageDetails,
457                ),
458              ],
459            },
460          },
461        },
462        listeners: [
463          SalesforceInteractions.listener("submit", ".premium-access-ajax", () => {
464            const eloquaId = findInDataLayer("EloquaGuid");
465            if (eloquaId) {
466              SalesforceInteractions.sendEvent({
467                interaction: { name: "Tableau Blog Sign-up" },
468                user: { attributes: { eloquaId: eloquaId } },
469              });
470            }
471          }),
472        ],
473        contentZones: [
474          /*
475                    As in this case below, content zones do not necessarily have to denote content to be replaced, they can be
476                    useful for inserting template content before or after the the DOM node with the provided selector.
477                    */
478          { name: "blog_text_content", selector: ".field--name-field-page-sections" },
479        ],
480      },
481    ],
482  };
483  SalesforceInteractions.initSitemap(config);
484});

Example Sitemap in the Evergage Namespace 

1Evergage.init({
2  cookieDomain: "tableau.com",
3}).then(() => {
4  const findInDataLayer = (targetAttribute) => {
5    if (!window.dataLayer) {
6      return;
7    }
8    for (let i = 0; i < window.dataLayer.length; i++) {
9      const result = Evergage.util.getValueFromNestedObject("window.dataLayer[" + i + "]");
10      if (result && result[targetAttribute]) {
11        return result;
12      }
13    }
14    return;
15  };
16
17  // We set a value for this variable only once per page load and then reference it several times for the data found in the dataLayer needed for each page.
18  let pageDetails;
19  const setPageDetailsFromDataLayer = () => {
20    pageDetails = pageDetails || findInDataLayer("entityId");
21  };
22
23  /*
24    Here we will first check session storage for the islogin item containing the user's email that is associated with
25    their account. This value is set in the global event listener in the site config when a submission event occurs. On this
26    website, both successful and invalid form submissions emit a submission event, the page also is quickly reloaded
27    after a successful login.
28    In this unusual situation, we will first save the submitted data in session storage. Then when the page loads,
29    we check for the islogin value in session storage and then look for a DOM element which indicates a successful
30    login before retrieving the stored user email and sending our login event.
31    */
32  let loginEvent = sessionStorage.getItem("islogin");
33  // if there is a stored login event...
34  if (loginEvent) {
35    // wait for the logged in user dropdown to appear in the nav, signifying a successful login
36    Evergage.DisplayUtils.pageElementLoaded(
37      "#block-public-sitewide-ui-author-profile-dropdown",
38    ).then(() => {
39      // remove the stored email from session storage
40      sessionStorage.removeItem("islogin");
41      // construct and send the Login Success event to Personalization
42      Evergage.sendEvent({
43        action: "Login Success",
44        user: {
45          /*
46                        The users email address provided in the login form is sent to Personalization as user.id because
47                        in this example, emailAddress is configured to be the default identity for the web channel.
48                        */
49          id: loginEvent,
50        },
51      });
52    });
53  }
54
55  const config = {
56    global: {
57      contentZones: [
58        { name: "global_popup" },
59        { name: "global_infobar" },
60        { name: "global_exit_intent" },
61        /*
62                Since the website does not have consistent selectors and structure within the Article pages,
63                this content zone will be used to add recs to the bottom of Article pages.
64                */
65        { name: "global_footer", selector: "footer.global-footer" },
66      ],
67      listeners: [
68        /*
69                Here we are listening for all submission events that happen within this document. This pattern
70                can be used to create generic form submission event handlers, or even just to consolidate them
71                to one place in the sitemap code.
72                */
73        Evergage.listener("submit", document, (event) => {
74          // Check the id of the event target to check for the login form we want to scrape data from
75          if (event.target.id === "login-form") {
76            // loop through login form fields...
77            for (i = 0; i < event.target.length; i++) {
78              // Find the email field by id within the event object.
79              if (event.target[i].id === "login-email") {
80                // save user email in session storage
81                sessionStorage.setItem("islogin", event.target[i].value);
82              }
83            }
84          }
85        }),
86      ],
87    },
88    pageTypeDefault: {
89      name: "default",
90    },
91    pageTypes: [
92      {
93        name: "home",
94        action: "Homepage",
95        isMatch: () => /^\/$/.test(window.location.pathname),
96        contentZones: [
97          { name: "home_recs_1", selector: "section.datalocation-audience-segment-links" },
98          { name: "home_recs_2", selector: "section.datalocation-customer-stories" },
99        ],
100      },
101      {
102        name: "products_landing",
103        action: "Products Landing",
104        isMatch: () => /^\/products\/?$/.test(window.location.pathname),
105        catalog: {
106          Category: {
107            /*
108                        Remember, Catalog IDs are case sensitive. Transforming collected values as all upper case or all lower
109                        case is a common method for ensuring consistency throughout the site when creating Catalog IDs with the
110                        sitemap which do not need to correspond to data in external systems.
111                        */
112            _id: () =>
113              Evergage.util
114                .getLastPathComponentWithoutExtension(window.location.pathname)
115                .toLowerCase(),
116            url: Evergage.resolvers.fromCanonical(),
117            name: Evergage.resolvers.fromMeta("og:title"),
118          },
119        },
120        contentZones: [{ name: "products_landing_hero_banner", selector: "#hero" }],
121      },
122      {
123        name: "solutions_landing",
124        action: "Solutions Landing",
125        isMatch: () =>
126          /^\/solutions\/?(customers|industries|departments|technologies)?$/.test(
127            window.location.pathname,
128          ),
129        catalog: {
130          Category: {
131            /*
132                        The last parameter of each resolver function accepts a function which returns whatever the desired final value is.
133                        This is very useful when you need to transform or sanitize a scraped value before sending it to Interaction studio.
134
135                        You can learn more about resolver functions here: https://developer.salesforce.com/docs/marketing/personalization/guide/sitemap-implementation.html#resolvers
136
137                        Below, the pathname portion of the URL is transformed into a hierarchal Category id.
138                        */
139            _id: Evergage.resolvers.fromWindow("location.pathname", (path) =>
140              path.split("/").slice(1).join("|").toLowerCase(),
141            ),
142            url: Evergage.resolvers.fromCanonical(),
143            name: Evergage.resolvers.fromMeta("og:title"),
144          },
145        },
146        contentZones: [{ name: "solutions_landing_hero_banner", selector: "#hero" }],
147      },
148      {
149        name: "learning_landing",
150        action: "Learning Landing",
151        isMatch: () => /^\/learn\/?$/.test(window.location.pathname),
152        catalog: {
153          Category: {
154            _id: () =>
155              Evergage.util
156                .getLastPathComponentWithoutExtension(window.location.pathname)
157                .toLowerCase(),
158            url: Evergage.resolvers.fromCanonical(),
159            name: Evergage.resolvers.fromMeta("og:title"),
160          },
161        },
162        contentZones: [
163          {
164            name: "learning_landing_gray_recs",
165            selector: ".entity-paragraphs-item.paragraph--type--cross-reference",
166          },
167          { name: "learning_landing_hero_banner", selector: "#hero" },
168        ],
169      },
170      {
171        name: "blog_landing",
172        action: "Blog Landing",
173        isMatch: () => /^\/about\/blog\/?$/.test(window.location.pathname),
174        catalog: {
175          Category: {
176            _id: () =>
177              Evergage.util
178                .getLastPathComponentWithoutExtension(window.location.pathname)
179                .toLowerCase(),
180            url: Evergage.resolvers.fromCanonical(),
181            name: Evergage.resolvers.fromMeta("og:title"),
182          },
183        },
184        contentZones: [
185          { name: "blog_landing_card_wall", selector: ".card-wall" },
186          { name: "blog_landing_hero_banner", selector: "#hero" },
187        ],
188      },
189      {
190        name: "product",
191        action: "Product",
192        isMatch: () => {
193          if (/^\/products\//.test(window.location.pathname)) {
194            /*
195                        We only want to call setPageDetailsFromDataLayer() when pages which rely on scraping information from the dataLayer are actually matched.
196                        Adding a conditional statement prevents this function from being called on every page on the site as Personalization resolves every
197                        isMatch function to see which page type matches the current one.
198                        */
199            setPageDetailsFromDataLayer();
200            return true;
201          }
202          return false;
203        },
204        catalog: {
205          /*
206                    Remember, while this site does promote products, there is no ability to purchase products or attribute
207                    revenue to them from other channels which would match with user activity from the web.
208                    Since the special capabilities of the Product item type will not be needed for this implementation,
209                    we will instead use the Article item type, utilizing Category to tell types of Article apart.
210                    */
211          Article: {
212            /*
213                        We are using Evergage.util.getValueFromNestedObject() to more easily reference data from the pageDetails
214                        object after the value is set when the page matches.
215                        */
216            _id: () => Evergage.util.getValueFromNestedObject("entityId", pageDetails),
217            name: () => Evergage.util.getValueFromNestedObject("entityLabel", pageDetails),
218            url: Evergage.resolvers.fromCanonical(),
219            imageUrl: Evergage.resolvers.fromSelectorAttribute(
220              "div.feature-highlight__image img",
221              "src",
222            ),
223            categories: Evergage.resolvers.fromWindow("location.pathname", (path) => {
224              const categories = path.split("/").slice(1);
225              categories.pop();
226              if (categories.length === 0) {
227                const dataLayerCategories = Evergage.util.getValueFromNestedObject(
228                  "page.category1",
229                  pageDetails,
230                );
231                return dataLayerCategories ? [dataLayerCategories.toLowerCase()] : [];
232              }
233              return [categories.join("|").toLowerCase()];
234            }),
235            relatedCatalogObjects: {
236              // In case you're using 'dimensions' instead of 'relatedCatalogObjects', you can continue to do so as they both function the same way.
237              ProductType: () =>
238                window.location.pathname.indexOf("add-ons") > -1 ? ["Add-On"] : ["Software"],
239              Industry: () =>
240                Evergage.util.getValueFromNestedObject(
241                  "dataModelFields.field_dmo_industries",
242                  pageDetails,
243                ) || null,
244              Department: () =>
245                Evergage.util.getValueFromNestedObject(
246                  "dataModelFields.field_departments",
247                  pageDetails,
248                ) || null,
249              JobRole: () =>
250                Evergage.util.getValueFromNestedObject(
251                  "dataModelFields.taxonomy_vocabulary_23",
252                  pageDetails,
253                ) || null,
254              PageBundle: () => {
255                const pageBundle = Evergage.util.getValueFromNestedObject(
256                  "entityBundleNice",
257                  pageDetails,
258                );
259                return pageBundle ? [pageBundle] : null;
260              },
261              Keyword: Evergage.resolvers.fromMeta("keywords", (ele) => {
262                return ele ? ele.split(/\,\s*/) : null;
263              }),
264            },
265          },
266        },
267        listeners: [
268          Evergage.listener(
269            "submit",
270            "#webform-submission-email-embeddable-1-add-form",
271            (event) => {
272              Evergage.sendEvent({
273                action: "Free Trial Download",
274                /*
275                            A content zone is provided in this event in order to allow a campaign targeting the "global_infobar"
276                            content zone to be returned in the response sent back from Personalization with this request.
277                            */
278                source: {
279                  contentZones: ["global_infobar"],
280                },
281              });
282            },
283          ),
284        ],
285      },
286      {
287        name: "solutions",
288        action: "Solutions",
289        isMatch: () => {
290          if (
291            /\/solutions\/(?!customers|industries|departments|technologies)/.test(
292              window.location.pathname,
293            )
294          ) {
295            setPageDetailsFromDataLayer();
296            return true;
297          }
298          return false;
299        },
300        catalog: {
301          Article: {
302            _id: () => Evergage.util.getValueFromNestedObject("entityId", pageDetails),
303            name: () => Evergage.util.getValueFromNestedObject("entityLabel", pageDetails),
304            url: Evergage.resolvers.fromCanonical(),
305            imageUrl: Evergage.resolvers.fromSelectorAttribute(
306              "div.feature-highlight__image img",
307              "src",
308            ),
309            categories: Evergage.resolvers.fromWindow("location.pathname", (path) => {
310              const categories = path.split("/").slice(1);
311              categories.pop();
312              return [categories.join("|").toLowerCase()];
313            }),
314            relatedCatalogObjects: {
315              Industry: () =>
316                Evergage.util.getValueFromNestedObject(
317                  "dataModelFields.field_dmo_industries",
318                  pageDetails,
319                ) || null,
320              Department: () =>
321                Evergage.util.getValueFromNestedObject(
322                  "dataModelFields.field_departments",
323                  pageDetails,
324                ) || null,
325              JobRole: () =>
326                Evergage.util.getValueFromNestedObject(
327                  "dataModelFields.taxonomy_vocabulary_23",
328                  pageDetails,
329                ) || null,
330              PageBundle: () => {
331                const pageBundle = Evergage.util.getValueFromNestedObject(
332                  "entityBundleNice",
333                  pageDetails,
334                );
335                return pageBundle ? [pageBundle] : null;
336              },
337            },
338          },
339        },
340      },
341      {
342        name: "learning",
343        action: "Learning",
344        isMatch: () => {
345          if (/^\/learn\//.test(window.location.pathname)) {
346            setPageDetailsFromDataLayer();
347            return true;
348          }
349          return false;
350        },
351        catalog: {
352          Article: {
353            _id: () => Evergage.util.getValueFromNestedObject("entityId", pageDetails),
354            name: () => Evergage.util.getValueFromNestedObject("entityLabel", pageDetails),
355            url: Evergage.resolvers.fromCanonical(),
356            imageUrl: Evergage.resolvers.fromSelectorAttribute(
357              "div.feature-highlight__image img",
358              "src",
359            ),
360            categories: Evergage.resolvers.fromWindow("location.pathname", (path) => {
361              const categories = path.split("/").slice(1);
362              categories.pop();
363              return [categories.join("|").toLowerCase()];
364            }),
365            relatedCatalogObjects: {
366              Industry: () =>
367                Evergage.util.getValueFromNestedObject(
368                  "dataModelFields.field_dmo_industries",
369                  pageDetails,
370                ) || null,
371              Department: () =>
372                Evergage.util.getValueFromNestedObject(
373                  "dataModelFields.field_departments",
374                  pageDetails,
375                ) || null,
376              JobRole: () =>
377                Evergage.util.getValueFromNestedObject(
378                  "dataModelFields.taxonomy_vocabulary_23",
379                  pageDetails,
380                ) || null,
381              PageBundle: () => {
382                const pageBundle = Evergage.util.getValueFromNestedObject(
383                  "entityBundleNice",
384                  pageDetails,
385                );
386                return pageBundle ? [pageBundle] : null;
387              },
388            },
389          },
390        },
391      },
392      {
393        name: "blog",
394        isMatch: () => {
395          if (/\/about\/blog\/\d+\/\d+\/.+/.test(window.location.pathname)) {
396            setPageDetailsFromDataLayer();
397            return true;
398          }
399          return false;
400        },
401        catalog: {
402          Blog: {
403            _id: () => Evergage.util.getValueFromNestedObject("entityId", pageDetails),
404            name: () => Evergage.util.getValueFromNestedObject("entityLabel", pageDetails),
405            url: Evergage.resolvers.fromCanonical(),
406            imageUrl: Evergage.resolvers.fromMeta("og:image"),
407            categories: () => [
408              Evergage.util.getValueFromNestedObject("flatTaxonomy.blog_categories", pageDetails),
409            ],
410          },
411        },
412        listeners: [
413          Evergage.listener("submit", ".premium-access-ajax", () => {
414            const eloquaId = findInDataLayer("EloquaGuid");
415            if (eloquaId) {
416              Evergage.sendEvent({
417                action: "Tableau Blog Sign-up",
418                user: { attributes: { eloquaId: eloquaId } },
419              });
420            }
421          }),
422        ],
423        contentZones: [
424          /*
425                    As in this case below, content zones do not necessarily have to denote content to be replaced, they can be
426                    useful for inserting template content before or after the the DOM node with the provided selector.
427                    */
428          { name: "blog_text_content", selector: ".field--name-field-page-sections" },
429        ],
430      },
431    ],
432  };
433  Evergage.initSitemap(config);
434});

See Also