
【第625回】 Real-Time Offer Management : 顧客に合うオファーを Web サイトに表示する
前回の記事では、顧客が閲覧した商品のカテゴリに合わせて、案内するオファーを選ぶ Recommender を作成しました。
今回は、その結果を Web サイトに表示します。
TrailNest の商品詳細ページを開くと、商品とカテゴリの情報が Data 360 に送信されます。Personalization は、その顧客の閲覧履歴を参照して、案内するオファーを選びます。
さらに、メールアドレスを入力する欄も用意し、前の記事で設定した ID 解決につなげます。
今回は、商品閲覧の送信から、選ばれたオファーの Web 表示までを確認します。
Real-Time Offer Management シリーズ
今回作成する Web サイト
今回は、Real-Time Offer Management の検証用として、TrailNest の Web サイトを用意しました。

表示している商品は、これまで設定してきた次の 2 つのみです。
Trail Duo Tent
Alpine Down Sleeping Bag
Web サイトには、次の画面と機能を用意します。
トップページ
カテゴリページ
商品詳細ページ
オファーの表示領域
テスト顧客のメールアドレス入力欄
商品を追加できるカート画面
カートには、商品追加と小計表示まで用意します。クーポンによる割引計算と注文処理は、今回の実装には含めません。
まずは、商品閲覧によって案内する特典が変わることを確認します。
① CloudPages に HTML を配置する
今回の Real-Time Offer Management 用として用意した CloudPages を開き、以下を 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&category=tents">Tents</a>
<a href="?view=category&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 & 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 => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[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&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&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>
テナントエンドポイントを変更する
HTML 内で、次の設定を検索します。
endpoint: 'https://YOUR_DATA_360_TENANT.c360a.salesforce.com/personalization/decisions',YOUR_DATA_360_TENANT を、今回の Real-Time Offer Management 組織の Data 360 テナント名に置き換えます。テナント名は Data Cloud Setup 画面にあります。

その下の設定は、前回の記事で作成した内容に合わせてあります。
dataspace: 'default',
point: 'TrailNest_Offer_Recommendations',商品とカテゴリの ID
今回の HTML には、これまで確認した次の ID を設定しています。
Trail Duo Tent:01tWs00000HJotlIAD
Alpine Down Sleeping Bag:01tWs00000HJovNIAT
Tents:0ZGWs000001fGLBOA2
Sleeping:0ZGWs000001fGMnOAM
商品ページの URL には TN-TE-002 などの商品コードを使用しますが、Data 360 に送信する商品・カテゴリには、照合用の ID を使用します。
その対応関係を、今回のサンプルでは HTML 内に設定しています。
② Web SDK の読み込みタグを追加する
Data 360 で、前の記事で作成した Website Connector を開きます。
Name:TrailNest Offers Website
この Website Connector の Web SDK を読み込む script タグを取得します。

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. -->
この位置へ、取得した script タグを下記のように加工して貼り付けます。
<script src="https://cdn.c360a.salesforce.com/beacon/c360a/5404ac93-0fce-4bc1-9ac5-c39771369434/scripts/c360a.min.js"></script>
③ Sitemap を登録する
続いて、以下の全体を、Website Connector の 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();
})();
今回のファイルは、この検証用サイトに合わせた設定です。

主な処理は、次のとおりです。
Web サイトで、パーソナライズの利用に同意されるのを待つ。
Salesforce Interactions SDK を初期化する。
ブラウザの Profile 情報を送信する。
現在のページを判定する。
商品詳細ページの場合は、商品閲覧イベントを送信する。
CloudPages 側でオファーを取得できる状態にする。
SDK の基本的な順序は、init() による初期化後に、initSitemap() で Sitemap を設定する形です。
商品閲覧で送信する情報
商品詳細ページでは、次の情報を送信します。
Interaction:View Catalog Object
Catalog Object Type:Product
Catalog Object ID:Salesforce の商品 ID
categoryId:Salesforce のカテゴリ ID
コード上では、次の部分に該当します。
interaction: {
name: SI.CatalogObjectInteractionName.ViewCatalogObject,
catalogObject: {
type: 'Product',
id: root.dataset.productId,
attributes: {
name: root.dataset.productName,
categoryId: root.dataset.categoryId
}
}
}SDK の catalogObject.attributes.categoryId は、Web Connector 側では attributeCategoryId として扱われます。
④ 公開ページで SDK とオファー取得を確認する
CloudPages のトップの公開 URL を開きます。
最初は、次のボタンが表示されるのでクリックします。
Enable personalized offers

クリックすると、今回の検証用トラッキングに同意し、SDK の初期化とイベント送信を開始します。
今回のサンプルでは、この同意をタブ単位の sessionStorage に記録します。同じタブ内で商品ページへ移動しても、毎回ボタンを押す必要はありません。SDK は、トラッキングへの同意を受け取ってからデータ収集を開始する仕組みです。
商品をまだ閲覧していない状態で、共通オファーの見出し 「Your Next Adventure Starts Here」 が表示されることを確認します。

認証の設定について
今回は、Personalization Point の Authentication Required を未チェックにした構成です。CloudPages に Salesforce のログイン情報やアクセストークンを設定する必要はありません。
⑤ テスト顧客のメールアドレスを入力する
ページ下部の Make it personal に、CRM に登録済みのテスト顧客のメールアドレスを入力します。

続いて、Connect my activity をクリックします。

これで、入力したメールアドレスが Data 360 に送信されます。前の記事で設定したメールアドレスの一致ルールによって、Web の閲覧履歴を CRM の顧客とリアルタイムに関連付られます。リアルタイムの連携の結果は、データエクスプローラーで確認できます。

⑥ 商品を閲覧してオファーの変化を確認する
1. Tents のオファーを確認する
Trail Duo Tent をクリックし、商品詳細ページを開きます。

2. ページ内のオファー表示を確認して、共通オファーのままの場合は、少し時間を置き、Refresh offer をクリックします。

リアルタイムとは言え、イベントの取り込み・反映には時間がかかるため、「閲覧した瞬間に必ず切り替わる」わけではありません。
3. テントの 10% 割引を案内するオファーに変わることを確認します。

2. Sleeping のオファーを確認する
Sleeping だけを確認する場合は、別のブラウザプロファイルと、別のテスト顧客を使用します。
トップページを開き、Enable personalized offers をクリックします。
別のテスト顧客のメールアドレスを入力し、Connect my activity をクリックします。
Alpine Down Sleeping Bag をクリックします。
必要に応じて Refresh offer をクリックし、Sleeping の 15% 割引を案内するオファーに変わることを確認します。

同じ顧客が Tents と Sleeping の両方を閲覧した場合は、今回の優先順位では Tents の方が優先順位が高いので Tents のオファーが選ばれます。
⑦ そもそも今回やろうとしていることとは
これは振り返りになりますが、今回は「閲覧したカテゴリに関連するオファー(特典)を案内する」仕組みです。閲覧回数が多いカテゴリを選ぶ設定ではありません。
今回の設定では、次のように選びます。
Tents を閲覧した人:テントの 10% OFF オファー
Sleeping を閲覧した人:寝袋などの 15% OFF オファー
対象カテゴリの閲覧がない人:150ドル以上の購入で15ドル OFF の共通オファー(Weekend)
両方を閲覧した場合は、設定した優先順位で選びます。
Tents:優先順位 10
Sleeping:優先順位 20
今回は数字が小さい方を優先するため、Tents のオファーが表示されます。 Sleeping を何度も見たり、最後に見たりしても、その理由で Sleeping に切り替わる構成ではありません。
分かりやすく言えば、テントを一度でも見たら、テントのクーポンが出続けます。ただし「一度見たら永久に」ではなく、今回設定した過去 7 日間の閲覧データに Tents が残っている間だけです。
クーポンに優先順位をつけ、他のカテゴリより、より興味のあるカテゴリのある人に、関連するクーポンを優先的に出すという内容になっています。
また、現段階では「特典の案内」を表示しているだけです。クーポンコードの発行や、カート金額への割引適用はまだ行っていません。そこは次の実装で対応する予定です。
いかがでしたでしょうか。
今回は、CloudPages から商品閲覧を送信し、Personalization が選んだオファーを Web サイトへ表示する構成を用意しました。
顧客の閲覧情報と、Offer に関連付けたカテゴリを比較することで、その顧客に案内する特典を切り替えます。
また、メールアドレスを送信することで、前の記事で準備した CRM 顧客との照合にもつながります。
次回は、案内した特典を実際のカートへ適用する処理に進みます。
今回は以上です。