Sitemap
Nobuyuki Watanabe @marketingcloudtips

Nobuyuki Watanabe @marketingcloudtips

I publish articles about Salesforce Marketing Cloud, Agentforce and Data Cloud (Data 360). I hope they can be helpful for your learning and day-to-day work.

SFMC Tips #404 : Real-Time Offer Management: Displaying Personalized Offers on a Website

--

Press enter or click to view image in full size
Photo by sentidos humanos on Unsplash

In the previous article, we created a Recommender that selects offers based on the categories of products viewed by customers.

This time, we will display those results on a website.

When a customer opens a product detail page on TrailNest, product and category information is sent to Data 360. Personalization refers to that customer’s browsing history and selects an offer to present.

We will also provide a field for entering an email address to connect this to the identity resolution configured in the previous article.

This time, we will check the process from sending product views to displaying the selected offers on the website.

The Website We Will Create

This time, we have prepared a TrailNest website for testing Real-Time Offer Management.

Press enter or click to view image in full size

Only the following two products, which we have configured so far, are displayed.

  • Trail Duo Tent
  • Alpine Down Sleeping Bag

We will provide the following screens and features on the website.

  • Home page
  • Category pages
  • Product detail pages
  • Offer display area
  • Email address input field for test customers
  • Cart screen where products can be added

The cart will support adding products and displaying the subtotal. Coupon discount calculations and order processing are not included in this implementation.

First, we will check that the offer presented changes based on product views.

① Place the HTML in CloudPages

Open the CloudPages page prepared for this Real-Time Offer Management implementation and paste the following into the CloudPages HTML.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TrailNest — A little closer to the wild</title>

<style>
:root {
--green:#183b2c;
--paper:#f6f4ec;
--ink:#20392c;
--line:#dce3d8;
--accent:#e5b66b;
}
* { box-sizing:border-box; }
body {
margin:0;
background:var(--paper);
color:var(--ink);
font:16px/1.6 Arial,Helvetica,sans-serif;
}
button,input { font:inherit; }
a { color:inherit; }

button,.button {
display:inline-flex;
align-items:center;
justify-content:center;
border:0;
border-radius:999px;
padding:12px 22px;
background:var(--green);
color:#fff;
text-decoration:none;
cursor:pointer;
font-weight:700;
}
button:disabled { opacity:.5; cursor:wait; }

a:focus-visible,
button:focus-visible,
input:focus-visible {
outline:3px solid #b17c37;
outline-offset:4px;
}

[hidden] { display:none!important; }

header {
background:#fff;
border-bottom:1px solid var(--line);
}
nav {
max-width:1120px;
margin:auto;
padding:20px 24px;
display:flex;
align-items:center;
gap:22px;
}
.brand {
font-size:25px;
font-weight:800;
letter-spacing:-1px;
text-decoration:none;
}
.nav-links {
display:flex;
gap:20px;
margin-left:auto;
}
.cart-link {
white-space:nowrap;
font-weight:700;
}
main {
max-width:1120px;
margin:auto;
padding:28px 24px 60px;
}
.hero {
padding:64px 40px;
border-radius:28px;
background:
linear-gradient(
90deg,
rgba(17,45,31,.92),
rgba(17,45,31,.3)
),
url('https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/Default_Hero.png')
center/cover;
color:white;
}
.eyebrow {
text-transform:uppercase;
letter-spacing:2px;
font-size:12px;
font-weight:700;
}
.hero h1 {
max-width:650px;
font-size:clamp(36px,6vw,64px);
line-height:1.06;
margin:18px 0;
}
.hero p { max-width:480px; }
.hero .button {
background:var(--accent);
color:var(--green);
}
.section-head {
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
margin:32px 0 18px;
}
h2 {
font-size:28px;
line-height:1.2;
}
.grid {
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:24px;
}
.card,.panel {
background:#fff;
border:1px solid var(--line);
border-radius:22px;
overflow:hidden;
}
.card a { text-decoration:none; }
.card img {
display:block;
width:100%;
height:260px;
object-fit:contain;
background:#edf0e7;
padding:20px;
}
.card-body,.panel { padding:26px; }
.card h3 {
font-size:24px;
margin:8px 0;
}
.price {
font-size:25px;
font-weight:700;
}
.detail {
display:grid;
grid-template-columns:1fr 1fr;
gap:36px;
align-items:center;
}
.detail img {
width:100%;
height:420px;
object-fit:contain;
background:#edf0e7;
border-radius:24px;
}
.detail h1 {
font-size:40px;
line-height:1.15;
}
.muted {
color:#647468;
font-size:14px;
}
.offer-area { margin:32px 0; }
.offer-area h2 { margin:0; }
.offer-body {
overflow-wrap:anywhere;
min-width:0;
}
.offer-body img {
max-width:100%;
height:auto;
}
.offer-cta { margin-top:16px; }
.status {
font-size:14px;
color:#536759;
}
.identity { margin-top:32px; }
.identity form {
display:flex;
gap:12px;
flex-wrap:wrap;
}
.identity input {
flex:1;
min-width:180px;
border:1px solid #b4c1b3;
border-radius:10px;
padding:12px;
}
.cart-row {
display:flex;
gap:16px;
align-items:center;
border-bottom:1px solid var(--line);
padding:18px 0;
}
.cart-row .item-info { flex:1; }
.cart-row button { padding:8px 14px; }
.summary {
text-align:right;
padding-top:20px;
}
.consent {
margin:24px 0;
background:#e9eddf;
border-radius:18px;
padding:22px;
display:flex;
align-items:center;
justify-content:space-between;
gap:20px;
}
.consent p {
margin:0;
max-width:640px;
}
.secondary {
background:#e7ecdf;
color:var(--green);
}
footer {
max-width:1120px;
margin:auto;
padding:24px;
text-align:right;
font-size:12px;
letter-spacing:1px;
border-top:1px solid var(--line);
}
details { margin-top:24px; }
pre {
white-space:pre-wrap;
overflow-wrap:anywhere;
font-size:12px;
}
label {
display:block;
margin-bottom:10px;
}
.actions {
display:flex;
gap:12px;
flex-wrap:wrap;
}

@media(max-width:640px) {
nav {
padding:16px;
gap:12px;
flex-wrap:wrap;
}
.brand { font-size:22px; }
.nav-links {
order:3;
width:100%;
font-size:14px;
}
.cart-link { margin-left:auto; }
main { padding:20px 16px 40px; }
.hero { padding:38px 24px; }
.grid,.detail { grid-template-columns:1fr; }
.detail img { height:280px; }
.detail h1 { font-size:32px; }
.section-head {
align-items:flex-start;
flex-direction:column;
}
.consent {
align-items:flex-start;
flex-direction:column;
}
.identity form { flex-direction:column; }
.cart-row { flex-wrap:wrap; }
.panel { padding:20px; }
}
#offer-content > section {
box-sizing: border-box;
padding: 32px 36px !important;
border-radius: 20px !important;
}
@media (max-width: 640px) {
#offer-content > section {
padding: 26px 22px !important;
}
}
</style>
</head>

<body>

<header>
<nav>
<a class="brand" href="?">
TrailNest<span style="color:#82926c">.</span>
</a>

<div class="nav-links">
<a href="?view=category&amp;category=tents">Tents</a>
<a href="?view=category&amp;category=sleeping">Sleeping</a>
</div>

<a class="cart-link" href="?view=cart">
Bag (<span id="cart-count">0</span>)
</a>
</nav>
</header>

<main id="trailnest-app">

<div id="page-content"></div>

<div id="consent-panel" class="consent">
<p>
Enable personalized offers for this demo. TrailNest will use a
browser identifier and your product browsing activity to suggest offers.
</p>
<button id="enable-personalization" type="button">
Enable personalized offers
</button>
</div>

<section class="offer-area" aria-labelledby="offer-title">
<div class="section-head">
<div>
<p class="eyebrow">A LITTLE EXTRA FOR YOUR ADVENTURE</p>
<h2 id="offer-title">An offer for your next escape</h2>
</div>

<button id="refresh-offer" type="button" disabled>
Refresh offer
</button>
</div>

<p id="offer-status" class="status" role="status">
Enable personalization to discover your offer.
</p>

<div id="offer-content" class="offer-body" hidden></div>
</section>

<section class="panel identity">
<h2>Make it personal</h2>

<p class="muted">
For this demo, enter the email address of a test customer already
in CRM. This is not a login or an email subscription.
</p>

<form id="email-form">
<div style="flex:1">
<label for="demo-email">Test customer email</label>
<input
id="demo-email"
type="email"
required
autocomplete="off"
style="width:100%"
>
</div>

<button id="email-submit" disabled type="submit">
Connect my activity
</button>
</form>

<p id="email-status" class="status" role="status"></p>
</section>

<details id="debug-panel" hidden>
<summary>Demo diagnostics</summary>
<p>Browser ID: <code id="browser-id">Not ready</code></p>
<pre id="debug-output"></pre>
</details>

</main>

<footer>
TRAILNEST DEMO · DESIGNED &amp; BUILT BY NOBUYUKI WATANABE
</footer>

<script>
/*
* Replace YOUR_DATA_360_TENANT with the tenant name
* of your Real-Time Offer Management org.
*/
window.TrailNestOfferConfig = {
endpoint:
'https://YOUR_DATA_360_TENANT.c360a.salesforce.com/personalization/decisions',

dataspace: 'default',
point: 'TrailNest_Offer_Recommendations',

webChannelId: '0eFWs000000ZQKmMAO',
treatmentsField: 'ssot__OfferTreatment__dlm',

consentKey: 'trailnest-offers-demo-consent-v1',
cartKey: 'trailnest-offers-demo-cart-v1',

products: [
{
code: 'TN-TE-002',
id: '01tWs00000HJotlIAD',
categoryId: '0ZGWs000001fGLBOA2',
category: 'tents',
name: 'Trail Duo Tent',
price: 249,
image:
'https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/TN-TE-002.png',
description:
'A welcoming home for two, from quiet lakesides to mountain mornings.'
},
{
code: 'TN-SL-001',
id: '01tWs00000HJovNIAT',
categoryId: '0ZGWs000001fGMnOAM',
category: 'sleeping',
name: 'Alpine Down Sleeping Bag',
price: 199,
image:
'https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/TN-SL-001.png',
description:
'Pack light. Sleep comfortably. Wake ready for the next adventure.'
}
]
};

(() => {
'use strict';

const C = window.TrailNestOfferConfig;
const $ = id => document.getElementById(id);

const params = new URLSearchParams(location.search);
const root = $('trailnest-app');
const content = $('page-content');

const getStored = (key, fallback) => {
try {
return JSON.parse(sessionStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};

const setStored = (key, value) => {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// Continue without session persistence.
}
};

let cart = getStored(C.cartKey, {});

if (!cart || typeof cart !== 'object' || Array.isArray(cart)) {
cart = {};
}

for (const key of Object.keys(cart)) {
if (
!C.products.some(product => product.code === key) ||
!Number.isInteger(cart[key]) ||
cart[key] < 1 ||
cart[key] > 99
) {
delete cart[key];
}
}

const money = amount =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);

const escape = value =>
String(value).replace(/[&<>"']/g, character => ({
'&': '&amp;',
'<': '<',
'>': '>',
'"': '"',
"'": '&#39;'
}[character]));

/*
* The Sitemap uses this bridge to wait for consent
* and notify the page when the SDK is ready.
*/
let resolveConsent;

const consent = new Promise(resolve => {
resolveConsent = resolve;
});

let resolveReady;
let rejectReady;

const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});

window.TrailNestOffersBridge = {
consent,
ready,
resolveReady,
rejectReady
};

function accept() {
setStored(C.consentKey, true);
$('consent-panel').hidden = true;
resolveConsent();

$('offer-status').textContent =
'Preparing personalized offers…';
}

$('enable-personalization').addEventListener('click', accept);

if (getStored(C.consentKey, false) === true) {
accept();
}

/*
* Page rendering.
*/
const selected = C.products.find(
product => product.code === params.get('id')
);

const view = params.get('view') || 'home';

root.dataset.pageType =
view === 'product' && selected
? 'product_detail'
: view === 'cart'
? 'cart'
: view === 'category'
? 'category'
: 'home';

if (root.dataset.pageType === 'product_detail') {
Object.assign(root.dataset, {
productId: selected.id,
productCode: selected.code,
categoryId: selected.categoryId,
productName: selected.name
});

content.innerHTML = `
<p>
<a href="?">Home</a> / ${escape(selected.category)}
</p>

<section class="detail">
<img
src="${selected.image}"
alt="${escape(selected.name)}"
>

<div>
<p class="eyebrow">
TRAILNEST · ${escape(selected.category)}
</p>

<h1>${escape(selected.name)}</h1>
<p>${escape(selected.description)}</p>
<p class="price">${money(selected.price)}</p>

<div class="actions">
<button
type="button"
data-add="${selected.code}"
>
Add to bag
</button>

<a class="button secondary" href="?view=cart">
View bag
</a>
</div>

<p id="cart-message" role="status"></p>
</div>
</section>
`;
} else if (view === 'cart') {
renderCart();
} else {
const list = view === 'category'
? C.products.filter(
product => product.category === params.get('category')
)
: C.products;

const hero = view === 'category' ? '' : `
<section class="hero">
<p class="eyebrow">
TRAILNEST · FIND YOUR NEXT CHAPTER
</p>

<h1>A little closer<br>to the wild.</h1>

<p>
Thoughtful gear. Fresh air.
A few good reasons to get outside.
</p>

<a class="button" href="#shop">
Explore the collection
</a>
</section>
`;

content.innerHTML = hero + `
<div class="section-head" id="shop">
<h2>
${
view === 'category'
? 'Explore the collection'
: 'Made for your next escape'
}
</h2>
<span class="muted">
Two essentials. Endless possibilities.
</span>
</div>

<div class="grid">
${list.map(product => `
<article class="card">
<a href="?view=product&amp;id=${product.code}">
<img
src="${product.image}"
alt="${escape(product.name)}"
>

<div class="card-body">
<p class="eyebrow">
${escape(product.category)}
</p>

<h3>${escape(product.name)}</h3>
<p>${escape(product.description)}</p>
<p class="price">${money(product.price)}</p>
<span>Discover this gear →</span>
</div>
</a>
</article>
`).join('')}
</div>
`;
}

/*
* Demo cart.
*/
function updateCount() {
$('cart-count').textContent =
Object.values(cart).reduce((total, quantity) => {
return total + quantity;
}, 0);

setStored(C.cartKey, cart);
}

function renderCart() {
const rows = C.products.filter(product => cart[product.code]);

const subtotal = rows.reduce((total, product) => {
return total + product.price * cart[product.code];
}, 0);

content.innerHTML = `
<section class="panel">
<p class="eyebrow">YOUR NEXT ADVENTURE</p>
<h1>Your bag</h1>

${
rows.length
? rows.map(product => `
<div class="cart-row">
<div class="item-info">
<a href="?view=product&amp;id=${product.code}">
${escape(product.name)}
</a>
<div>
${money(product.price)} × ${cart[product.code]}
</div>
</div>

<button
type="button"
class="secondary"
data-remove="${product.code}"
>
Remove
</button>
</div>
`).join('')
: '<p>Your bag is ready for an adventure.</p>'
}

<div class="summary">
<strong>Subtotal ${money(subtotal)}</strong>
<p class="muted">
Demo prices. Promotions have not been applied.
Checkout is not available in this demo.
</p>
</div>

<a href="?">Continue exploring →</a>
</section>
`;
}

updateCount();

content.addEventListener('click', event => {
const add = event.target.closest('[data-add]');
const remove = event.target.closest('[data-remove]');

if (add) {
const code = add.dataset.add;

cart[code] = Math.min(99, (cart[code] || 0) + 1);

updateCount();

$('cart-message').textContent = 'Added to your bag.';
}

if (remove) {
delete cart[remove.dataset.remove];
updateCount();
renderCart();
}
});

$('debug-panel').hidden = params.get('debug') !== '1';

/*
* Render only supported presentation tags and CSS.
* Scripts and event handler attributes are not copied.
*/
function safeMarkup(html) {
const template = document.createElement('template');
const fragment = document.createDocumentFragment();

template.innerHTML = html;

const allowedTags = new Set([
'SECTION',
'DIV',
'P',
'H1',
'H2',
'H3',
'H4',
'SPAN',
'STRONG',
'EM',
'B',
'I',
'BR',
'UL',
'OL',
'LI'
]);

const allowedStyles = new Set([
'box-sizing',
'width',
'max-width',
'padding',
'padding-top',
'padding-bottom',
'margin',
'margin-top',
'margin-bottom',
'border',
'border-radius',
'background',
'background-color',
'color',
'font-family',
'font-size',
'font-weight',
'line-height',
'letter-spacing',
'text-align'
]);

function copy(node, parent) {
if (node.nodeType === 3) {
parent.append(document.createTextNode(node.textContent));
return;
}

if (
node.nodeType !== 1 ||
!allowedTags.has(node.tagName)
) {
return;
}

const element = document.createElement(
node.tagName.toLowerCase()
);

for (let index = 0; index < node.style.length; index++) {
const property = node.style.item(index);
const value = node.style.getPropertyValue(property);

if (
allowedStyles.has(property) &&
!/(url\s*\(|expression\s*\(|@import)/i.test(value)
) {
element.style.setProperty(property, value);
}
}

for (const child of node.childNodes) {
copy(child, element);
}

parent.append(element);
}

for (const node of template.content.childNodes) {
copy(node, fragment);
}

return fragment;
}

function safeUrl(value) {
try {
const url = new URL(value, location.href);

return ['https:', 'http:'].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
}

let sdk;
let busy = false;

/*
* Request and render the selected offer.
*/
async function refreshOffer() {
if (!sdk || busy) {
return;
}

busy = true;

$('refresh-offer').disabled = true;
$('offer-content').hidden = true;
$('debug-output').textContent = '';

$('offer-status').textContent =
'Finding an offer for you…';

const controller = new AbortController();

const timer = setTimeout(() => {
controller.abort();
}, 12000);

try {
if (C.endpoint.includes('YOUR_DATA_360_TENANT')) {
throw new Error(
'Replace YOUR_DATA_360_TENANT in the CloudPages HTML.'
);
}

const individualId = sdk.getAnonymousId();

if (!individualId) {
throw new Error('Browser ID is not available.');
}

$('browser-id').textContent = individualId;

const response = await fetch(C.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
signal: controller.signal,

body: JSON.stringify({
context: {
individualId,
dataspace: C.dataspace,
channel: 'Web',
requestUrl: location.href
},
personalizationPoints: [
{
name: C.point
}
]
})
});

if (!response.ok) {
throw new Error(
`Decision request failed: HTTP ${response.status}`
);
}

const payload = await response.json();

$('debug-output').textContent =
JSON.stringify(payload, null, 2);

const result = payload.personalizations?.find(
item => item.personalizationPointName === C.point
);

const offer = result?.data?.[0];

if (!offer) {
$('offer-status').textContent =
'No offer was returned. Please try again later.';
return;
}

/*
* Support both response shapes:
*
* ssot__OfferTreatment__dlm: { ... }
* ssot__OfferTreatment__dlm: [ { ... }, ... ]
*/
const rawTreatments = offer[C.treatmentsField];

const treatments = Array.isArray(rawTreatments)
? rawTreatments
: rawTreatments && typeof rawTreatments === 'object'
? [rawTreatments]
: [];

if (treatments.length === 0) {
throw new Error(
'Offer Treatment was not returned. Check the Content Schema.'
);
}

const treatment = treatments.find(item => {
return item &&
item.ssot__EngagementChannelType__c === C.webChannelId;
});

if (!treatment) {
throw new Error(
'No Web treatment was returned. Check the channel ID and Content Schema.'
);
}

const host = $('offer-content');
host.replaceChildren();

if (
treatment.ssot__OutputFormatType__c === 'HTML' &&
treatment.ssot__OutputText__c
) {
host.append(
safeMarkup(treatment.ssot__OutputText__c)
);
} else {
const panel = document.createElement('div');
panel.className = 'panel';

const heading = document.createElement('h2');
heading.textContent =
treatment.ssot__OfferHeadingText__c ||
offer.ssot__Name__c ||
'';

const body = document.createElement('p');
body.textContent =
treatment.ssot__OfferBodyText__c || '';

panel.append(heading, body);
host.append(panel);
}

const url = treatment.ssot__CallToActionUrl__c
? safeUrl(treatment.ssot__CallToActionUrl__c)
: null;

if (url && treatment.ssot__CallToActionText__c) {
const link = document.createElement('a');

link.className = 'button offer-cta';
link.href = url;
link.textContent = treatment.ssot__CallToActionText__c;

host.append(link);
}

host.dataset.offerId = offer.ssot__Id__c || '';
host.dataset.offerCode = offer.ssot__OfferCodeText__c || '';
host.dataset.personalizationId =
result.personalizationId || '';

host.hidden = false;

$('offer-status').textContent =
'Offer terms apply. Eligibility is checked when a promotion is applied.';

} catch (error) {
console.error('[TrailNest offers]', error);

$('offer-status').textContent =
'We could not load your offer. Please try again.';

$('debug-output').textContent += '\n' + error.message;

} finally {
clearTimeout(timer);
busy = false;
$('refresh-offer').disabled = false;
}
}

$('refresh-offer').addEventListener('click', refreshOffer);

/*
* Submit the test customer's email address.
*/
$('email-form').addEventListener('submit', async event => {
event.preventDefault();

if (!sdk) {
return;
}

const email = $('demo-email').value.trim();

if (!$('demo-email').reportValidity()) {
return;
}

$('email-submit').disabled = true;

try {
await sdk.sendEvent({
interaction: {
name: 'Customer Confirm Email'
},
user: {
attributes: {
eventType: 'contactPointEmail',
email
}
}
});

$('email-status').textContent =
'Your email event has been submitted. Profile matching may take time; refresh the offer after confirming the data.';

$('demo-email').value = '';

} catch (error) {
console.error('[TrailNest email]', error);

$('email-status').textContent =
'The email event could not be submitted. Please try again.';

} finally {
$('email-submit').disabled = false;
}
});

/*
* The existing Sitemap resolves this promise
* after initializing the SDK.
*/
ready.then(instance => {
sdk = instance;

$('email-submit').disabled = false;
$('refresh-offer').disabled = false;

void refreshOffer();

}).catch(error => {
console.error('[TrailNest SDK]', error);

$('offer-status').textContent =
'Personalization could not start. Check the SDK and sitemap setup.';
});

})();
</script>

<!-- PASTE THE WEB SDK SCRIPT TAG FROM "TrailNest Offers Website" HERE.
Register TrailNest_Offers_Sitemap.js in that same Website Connector.
Do not paste a second SDK initialization script into this HTML. -->

</body>
</html>
Press enter or click to view image in full size

Change the Tenant Endpoint

Search for the following setting in the HTML.

endpoint: 'https://YOUR_DATA_360_TENANT.c360a.salesforce.com/personalization/decisions',

Replace YOUR_DATA_360_TENANT with the Data 360 tenant name of the Real-Time Offer Management org used this time. The tenant name is on the Data Cloud Setup screen.

Press enter or click to view image in full size

The settings below it match what we created in the previous article.

dataspace: 'default',
point: 'TrailNest_Offer_Recommendations',

Product and Category IDs

The following IDs, which we have checked so far, are configured in this HTML.

  • Trail Duo Tent: 01tWs00000HJotlIAD
  • Alpine Down Sleeping Bag: 01tWs00000HJovNIAT
  • Tents: 0ZGWs000001fGLBOA2
  • Sleeping: 0ZGWs000001fGMnOAM

Product codes such as TN-TE-002 are used in the product page URLs, but IDs for matching are used for the products and categories sent to Data 360.

Get Nobuyuki Watanabe @marketingcloudtips’s stories in your inbox

Join Medium for free to get updates from this writer.

In this sample, that mapping is configured in the HTML.

② Add the Web SDK Loading Tag

In Data 360, open the Website Connector created in the previous article.

  • Name: TrailNest Offers Website

Obtain the script tag that loads this Website Connector’s Web SDK.

Press enter or click to view image in full size

The following comment is at the end of the CloudPages HTML.

<!-- PASTE THE WEB SDK SCRIPT TAG FROM "TrailNest Offers Website" HERE.
Register TrailNest_Offers_Sitemap.js in that same Website Connector.
Do not paste a second SDK initialization script into this HTML. -->
Press enter or click to view image in full size

Modify the script tag you obtained as shown below and paste it in this location.

<script src="https://cdn.c360a.salesforce.com/beacon/c360a/5404ac93-0fce-4bc1-9ac5-c39771369434/scripts/c360a.min.js"></script>
Press enter or click to view image in full size

③ Register the Sitemap

Next, register all of the following in the Website Connector’s Sitemap.

(() => {
'use strict';
async function start(){
const bridge=window.TrailNestOffersBridge;
if(!bridge){console.error('[TrailNest sitemap] The CloudPages bridge is missing. Place the SDK script after the page scripts.');return;}
try{
const SI=SalesforceInteractions;
await bridge.consent;
await SI.init({consents:[{purpose:'Tracking',provider:'TrailNest Offers Demo',status:'Opt In'}]});
// Keep the browser ID generated by the SDK. Do not replace it with a Contact ID.
await SI.sendEvent({interaction:{name:'TrailNest Profile Visit'},user:{attributes:{eventType:'identity',isAnonymous:'true'}}});
const root=document.getElementById('trailnest-app'),pageTypes=[];
if(root?.dataset.pageType==='product_detail'){
pageTypes.push({name:'product_detail',isMatch:()=>true,interaction:{
name:SI.CatalogObjectInteractionName.ViewCatalogObject,
catalogObject:{type:'Product',id:root.dataset.productId,attributes:{name:root.dataset.productName,categoryId:root.dataset.categoryId}}
}});
}else{
pageTypes.push({name:root?.dataset.pageType||'home',isMatch:()=>true,interaction:{name:'TrailNest Page View'}});
}
SI.initSitemap({global:{locale:'en_US'},pageTypes});
// SDK submission completion is not a guarantee that the graph has been updated.
bridge.resolveReady(SI);
}catch(error){bridge.rejectReady(error);}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else void start();
})();
Press enter or click to view image in full size

This file contains settings tailored to this test website.

Press enter or click to view image in full size

The main processing steps are as follows.

  • Wait for consent to use personalization on the website.
  • Initialize the Salesforce Interactions SDK.
  • Send the browser’s Profile information.
  • Determine the current page.
  • If it is a product detail page, send a product view event.
  • Make it possible to retrieve offers on the CloudPages side.

The basic SDK sequence is to initialize it using init() and then configure the Sitemap using initSitemap().

Information Sent for Product Views

On product detail pages, the following information is sent.

  • Interaction: View Catalog Object
  • Catalog Object Type: Product
  • Catalog Object ID: Salesforce product ID
  • categoryId: Salesforce category ID

This corresponds to the following part of the code.

interaction: {
name: SI.CatalogObjectInteractionName.ViewCatalogObject,
catalogObject: {
type: 'Product',
id: root.dataset.productId,
attributes: {
name: root.dataset.productName,
categoryId: root.dataset.categoryId
}
}
}

The SDK’s catalogObject.attributes.categoryId is handled as attributeCategoryId on the Web Connector side.

④ Check the SDK and Offer Retrieval on the Published Page

Open the published URL of the CloudPages home page.

The following button is displayed initially, so click it.

  • Enable personalized offers
Press enter or click to view image in full size

Clicking it gives consent to tracking for this test and starts SDK initialization and event submission.

In this sample, this consent is recorded in sessionStorage for each tab. You do not need to click the button every time you navigate to a product page within the same tab. The SDK starts collecting data after receiving consent to tracking.

Before viewing any products, confirm that the general offer heading, “Your Next Adventure Starts Here,” is displayed.

Press enter or click to view image in full size

Authentication Settings

In this configuration, Authentication Required is unchecked for the Personalization Point. You do not need to configure Salesforce login information or an access token in CloudPages.

⑤ Enter the Test Customer’s Email Address

In Make it personal at the bottom of the page, enter the email address of a test customer already registered in CRM.

Press enter or click to view image in full size

Next, click Connect my activity.

Press enter or click to view image in full size

This sends the entered email address to Data 360. The email address matching rule configured in the previous article links the web browsing history to the CRM customer in real time. You can check the results of this real-time linking in Data Explorer.

⑥ View Products and Check the Changes in Offers

1. Check the Tents Offer

1. Click Trail Duo Tent to open the product detail page.

Press enter or click to view image in full size

2. Check the offer displayed on the page. If it is still the general offer, wait a little and click Refresh offer.

Press enter or click to view image in full size

Although it is real time, event ingestion and updates take time, so the offer does not necessarily switch the instant a product is viewed.

3. Confirm that it changes to an offer presenting a 10% discount on tents.

Press enter or click to view image in full size

2. Check the Sleeping Offer

If you want to check Sleeping alone, use a different browser profile and a different test customer.

  1. Open the home page and click Enable personalized offers.
  2. Enter a different test customer’s email address and click Connect my activity.
  3. Click Alpine Down Sleeping Bag.
  4. Click Refresh offer as needed and confirm that it changes to an offer presenting a 15% discount on Sleeping.
Press enter or click to view image in full size

If the same customer views both Tents and Sleeping, the Tents offer is selected because Tents has a higher priority in the priorities configured this time.

⑦ What We Are Trying to Do in the First Place

To review, this implementation presents offers related to the categories that a customer has viewed. It is not configured to select the category with the most views.

With these settings, offers are selected as follows.

  • People who have viewed Tents: An offer for 10% off tents
  • People who have viewed Sleeping: An offer for 15% off sleeping bags and other items
  • People who have not viewed an eligible category: A general offer for $15 off purchases of $150 or more (Weekend)

If both categories have been viewed, the offer is selected based on the configured priorities.

  • Tents: Priority 10
  • Sleeping: Priority 20

This time, a lower number takes priority, so the Tents offer is displayed. The configuration does not switch to Sleeping because the customer has viewed Sleeping many times or viewed it most recently.

Put simply, once a customer views a tent, the tent coupon continues to appear. However, this does not mean “forever after viewing it once.” It applies only while Tents remains in the browsing data for the past seven days configured this time.

The coupons are assigned priorities so that related coupons are displayed preferentially to people who have a category they are more interested in than other categories.

Also, at this stage, we are only displaying information about the offer. We have not yet issued coupon codes or applied discounts to cart amounts. We plan to handle that in the next implementation.

What did you think?

This time, we prepared a configuration that sends product views from CloudPages and displays the offers selected by Personalization on the website.

By comparing the customer’s browsing information with the categories associated with an Offer, we change the offer presented to that customer.

Also, sending the email address connects this to the matching with CRM customers prepared in the previous article.

Next time, we will move on to the processing that applies the presented offer to the actual cart.

That’s all for this time.

Stay tuned for more Salesforce Marketing Cloud tips! 😎

Nobuyuki Watanabe

https://www.linkedin.com/in/nobuyuki-watanabe/ (+Follow me)

--

--

Nobuyuki Watanabe @marketingcloudtips
Nobuyuki Watanabe @marketingcloudtips

Written by Nobuyuki Watanabe @marketingcloudtips

I publish articles about Salesforce Marketing Cloud, Agentforce and Data Cloud (Data 360). I hope they can be helpful for your learning and day-to-day work.