
【第606回】Personalization:商品詳細ページに WPM レコメンドを表示する
前回の記事では、顧客の Affinity を Recommender から直接参照し、最高スコアのカテゴリの商品を、過去 2 週間の閲覧回数順で選ぶ設定を用意しました。
今回は、Experience Template と Web Personalization Manager(WPM)を使って、TrailNest の商品詳細ページにおすすめ商品を表示します。


Personalization 基礎シリーズ
Sitemap Builder シリーズ
Data 360 設定シリーズ
Personalization 設定シリーズ
今回の表示方法
TrailNest には 3 箇所のレコメンド掲出画面があり、TrailNest のトップページでは、以前の記事で実装した API レコメンドを継続します。また、注文完了ページでも、同じく API の表示を使用します。
今回は商品詳細ページにのみ WPM で商品レコメンドを入れます。

トップページ:既存の API レコメンド
商品詳細ページ:WPM のレコメンドのみ
注文完了ページ:既存の API レコメンド。
WPM の表示は、前回の記事で作成した次の設定を使用します。
Personalization Point:TrailNest WPM Category Popular Recommendations
Content Schema:TrailNest Category Recommendation Content
Affinity がある顧客:TrailNest Category Popular Recommendations
Affinity がない顧客:TrailNest All Popular Recommendations
商品数:最大 4 件。
① 商品詳細ページの表示場所を変更する
商品詳細ページには、すでに次の API 用の表示場所があります。
<div id="product_detail_recs_row_1" class="recommendation-zone" hidden></div>「product_detail_recs_row_1」で検索して、上記の部分を探し、以下に変更します。
<div id="product_detail_wpm_recs" class="tn-wpm-recommendation-zone"></div>この要素に、WPM が Experience Template の商品カードを表示します。こちらには hidden は付けません。

WPM 用の表示場所に hidden を付けない理由
hidden は、その要素を非表示にする指定です。以前の API 方式では、商品を取得するまで表示場所を隠し、JavaScript で商品カードを作成した後に hidden を解除していました。
今回は、商品カードの表示を WPM に任せるため、CloudPages 側に hidden を解除する処理を用意しません。そのため、WPM 用の表示場所には最初から hidden を付けません。
② 商品詳細ページを API の表示対象から外す
ここでは商品詳細を WPM 専用に切り替える構成を行います。
既存の API の JavaScript は、ページの種類に応じて商品カードを表示する場所を選んでいます。
レコメンド用の <script> の中で「#product_detail_recs_row_1」で検索して、次の selectors を確認します。
const selectors = {
home: "#home_recs",
product_detail: "#product_detail_recs_row_1",
order_confirmation: "#order_confirmation_recs_row_1"
};これを、次の内容に変更します。
const selectors = {
home: "#home_recs",
order_confirmation: "#order_confirmation_recs_row_1"
};
これは、商品詳細ページだけを、API の商品表示の対象から外しています。 既存の処理は表示先がなければ終了するため、商品詳細では API のカテゴリ取得・商品取得を行いません。
同じく、「#product_detail_recs_row_1」で検索すると、以下のスタイルが見つかると思います。
<style>
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1)[hidden] { display:none; }
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1).tn-recs-ready {
border:0; padding:0; text-align:left; color:#1d2a22;
}
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid {
display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:22px;
}
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .product-card { display:block; min-width:0; }
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .product-image { display:block; height:230px; }
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) a:focus-visible { outline:3px solid #234b35; outline-offset:4px; }
@media(max-width:900px) {
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media(max-width:480px) {
:is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid { grid-template-columns:1fr; }
}
</style>こちらも以下に変更します。
<style>
:is(#home_recs, #order_confirmation_recs_row_1)[hidden] {
display:none;
}
:is(#home_recs, #order_confirmation_recs_row_1).tn-recs-ready {
border:0;
padding:0;
text-align:left;
color:#1d2a22;
}
:is(#home_recs, #order_confirmation_recs_row_1) .tn-recs-grid {
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:22px;
}
:is(#home_recs, #order_confirmation_recs_row_1) .product-card {
display:block;
min-width:0;
}
:is(#home_recs, #order_confirmation_recs_row_1) .product-image {
display:block;
height:230px;
}
:is(#home_recs, #order_confirmation_recs_row_1) a:focus-visible {
outline:3px solid #234b35;
outline-offset:4px;
}
@media(max-width:900px) {
:is(#home_recs, #order_confirmation_recs_row_1) .tn-recs-grid {
grid-template-columns:repeat(2,minmax(0,1fr));
}
}
@media(max-width:480px) {
:is(#home_recs, #order_confirmation_recs_row_1) .tn-recs-grid {
grid-template-columns:1fr;
}
}
</style>
最後に表示場所を整えるため、</body> の直前に次の CSS を追加します。これは、WPM の枠が空のときに枠を表示しないための設定です。
<style>
/* Product-detail recommendations are rendered only by WPM. */
#product_detail_wpm_recs {
box-sizing:border-box;
display:block;
width:100%;
min-width:0;
min-height:0;
border:0;
padding:0;
}
#product_detail_wpm_recs:empty {
display:none;
}
</style> 
これで一旦、商品詳細ページのレコメンドが消えました。

③ Experience Template を作成する
Personalization アプリの Experience Templates を開き、新規作成します。
Channel:Web
Name:TrailNest Category Product Cards
API Name:TrailNest_Category_Product_Cards

Connected Websites:現在 TrailNest が使用している Website Connector

Template Type:Handlebars
Creation Method / Content Source:Global Template
Global Template:Simple Recommendations

Content Schema:TrailNest Category Recommendation Content
今回は「標準のレコメンド用テンプレート」(Global Template)を使い、商品の取得と表示をつなぎます。
テンプレートの各変数には、次の商品の項目を対応付けます。
image:Goods Product > Image URL
name:Goods Product > Product Name
price:Goods Product > Unit Price
url:Goods Product > Link URL

この対応付けは「返された商品の、どの値を画像・価格・名前・リンクに使うか」を決める設定です。商品の順位は、前回設定した Recommender が決めます。
Turn off auto-engagement tracking はチェックせず、まずは標準の HTML を使用します。

Save & Activate で有効にします。
④ 商品詳細ページで WPM を開く
公開済み TrailNest の商品詳細ページを開きます。
すでに ?view=product&id=... があるため、URL の末尾に次のパラメータを追加します。
&sf_personalization_wpm
ログインを求められた場合は、今回使用している Salesforce 環境へログインします。

⑤ WPM の表示先を商品詳細ページに限定する
Personalization Experiences → Add New から Embedded Content を選択します。
Personalization Point:TrailNest WPM Category Popular Recommendations

Experience Template:TrailNest Category Product Cards

Location タブで、次の内容を指定します。
表示対象:Current Page Type(Product Detail)
Display Method:Replace an Element
Page Element:#product_detail_wpm_recs
※ 今回は #product_detail_wpm_recs を直接入力します。

Content タブでは、これまでの取り込み設定を使用するため、Engagement Destination:Website Engagement を選択します。
Template Setting では TrailNest_Category_Product_Cards を選択します。

⑥ Affinity がある顧客でプレビューする
Preview Settings タブを開きます。
Show Overlays:有効
Show All Personalization Experiences:無効
Personalization Decisions and Cohorts:Current User Decision
※ Individual ID を入れてプレビューをするような場合は「Current User Decision」を利用します。
まず、Profile Data Graph で Affinity を確認できている顧客を使用します。必要に応じて、Individual ID に対象顧客の ID を指定します。
Preview をクリックして、次の内容を確認します。
商品詳細ページに WPM の商品カードが表示される。
商品詳細に、WPM の商品カードだけが表示される。
返された商品が、その顧客の最高スコアのカテゴリに該当する。
そのカテゴリの中で、過去 14 日間の閲覧回数が多い順になっている。
商品が最大 4 件である。
商品リンクをクリックすると、対応する商品詳細へ移動する。

⑦ Affinity がない顧客も確認する
次に、Affinity のレコードがない顧客を WPM の Individual ID に指定し、Current User Decision でプレビューします。
これは単純に上で利用した ID の下 1 桁を別のものに変えるなどすれば OK です。
Affinity 用 Decision を直接選択すると、通常のターゲティングによる出し分けの確認にならないため、今回は Current User Decision を使用します。
次の内容を確認します。
全カテゴリ用 Decision が選ばれる。
全カテゴリの過去 14 日間の閲覧回数が多い順に商品が返る。
最大 4 件が表示される。

全体に過去 14 日間の集計対象商品がない場合は、全カテゴリ用でも商品を返せません。この場合は商品カードを表示しません。既存 API に切り替えて商品を補う処理は追加していません。
⑧ 確認後に WPM の Experience を公開する
プレビューで確認できたら、今回の商品詳細用 Experience の State を Enabled にして、Save をクリックします。

その後、WPM を閉じてページを再読み込みし、通常の画面で次の内容を確認します。
トップページ:既存の API レコメンドが表示される。

商品詳細ページ:WPM のレコメンドが表示される。

注文完了ページ:既存の API レコメンドが表示される。

商品詳細の WPM 表示を停止する場合は、今回の商品詳細用 Experience を Disabled にして Save し、WPM を閉じてページを再読み込みします。商品詳細のカード表示は停止します。トップページと注文完了ページの API 表示、既存の Hero は継続します。
⑨ デザインと円記号を変更する
今回、API で表示していた商品カードと違い、WPM の商品カードには金額の前に $ が表示されていることに気づいた方もいるかもしれません。
これは、今回使用した Global Template の HTML に、次のように $ が直接書かれているためです。
<div class="sfdcep-recs-item-price">$ {{subVar 'price'}}</div>
商品データの価格が円の金額であれば、この記号を ¥ に変更します。これは表示する通貨記号の変更であり、米ドルから日本円への為替換算ではありません。
また、商品カードの色や配置、スマートフォンでの表示も、Experience Template の HTML と CSS を変更することで調整できます。
今回は、TrailNest の緑を基調とした商品カードに変更し、横スクロール型のカルーセルにします。
PC(画面幅が 900px より大きい場合):3商品を横に並べ、4商品目は横スクロールで表示する
タブレット(画面幅が 600px より大きく、900px 以下の場合):2商品を横に並べる
スマートフォン(画面幅が 600px 以下の場合):1商品と次の商品を少し表示し、スワイプで移動できるようにする
商品カード全体:クリックすると商品ページへ移動する
一旦、Point の Maximum Number of Recommendations to Return は、これまでどおり4件とします。「取得する商品数」と「一度に画面に見せる商品数」は別の設定です。
Experience Template のコードを置き換える
作成した TrailNest Category Product Cards を編集し、HTML のコード全体を次の内容に置き換えます。
このコードには、上の「 ¥ 」への変更も含まれます。
image・name・price・url の変数と、Default Value の商品項目への紐付けは変更しません。
<style>
.tn-wpm-recs {
--tn-green:#234b35;
--tn-ink:#182d22;
box-sizing:border-box;
width:100%;
max-width:1440px;
margin:0 auto;
padding:32px 0;
color:var(--tn-ink);
font-family:Arial,Helvetica,sans-serif;
}
.tn-wpm-recs *, .tn-wpm-recs *::before, .tn-wpm-recs *::after {
box-sizing:border-box;
}
.tn-wpm-recs__heading {
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:24px;
margin:0 0 20px;
}
.tn-wpm-recs__eyebrow {
margin:0 0 10px;
color:var(--tn-green);
font-size:11px;
font-weight:700;
letter-spacing:2px;
}
.tn-wpm-recs__title {
margin:0;
color:var(--tn-ink);
font-size:clamp(26px,3vw,38px);
line-height:1.15;
letter-spacing:-1px;
}
.tn-wpm-recs__hint {
flex-shrink:0;
margin:0 0 3px;
color:#647369;
font-size:12px;
}
.tn-wpm-recs__track {
display:flex;
gap:24px;
width:100%;
overflow-x:auto;
padding:8px 0 20px;
scroll-snap-type:x mandatory;
scrollbar-width:thin;
scrollbar-color:#728e7a #e8eee8;
}
.tn-wpm-recs__track::-webkit-scrollbar { height:8px; }
.tn-wpm-recs__track::-webkit-scrollbar-track {
border-radius:20px;
background:#e8eee8;
}
.tn-wpm-recs__track::-webkit-scrollbar-thumb {
border-radius:20px;
background:#728e7a;
}
.tn-wpm-recs__card {
flex:0 0 calc((100% - 48px) / 3);
min-width:0;
margin:0;
scroll-snap-align:start;
}
.tn-wpm-recs__link {
display:flex;
flex-direction:column;
height:100%;
overflow:hidden;
border:1px solid #dde5dc;
border-radius:18px;
background:#fff;
color:var(--tn-ink);
text-decoration:none;
transition:border-color .2s,box-shadow .2s,transform .2s;
}
.tn-wpm-recs__link:hover {
border-color:#8fa793;
box-shadow:0 8px 20px rgba(24,45,34,.08);
transform:translateY(-3px);
}
.tn-wpm-recs__link:focus-visible,
.tn-wpm-recs__track:focus-visible {
outline:3px solid #b17c37;
outline-offset:-3px;
}
.tn-wpm-recs__visual {
display:flex;
align-items:center;
justify-content:center;
aspect-ratio:4 / 3;
padding:24px;
background:linear-gradient(145deg,#f5f7f1,#e9efe6);
}
.tn-wpm-recs__image {
display:block;
width:100%;
max-width:none;
height:100%;
min-height:0;
object-fit:contain;
}
.tn-wpm-recs__body {
display:flex;
flex:1;
flex-direction:column;
padding:24px;
}
.tn-wpm-recs__name {
margin:0 0 18px;
color:var(--tn-ink);
font-size:20px;
font-weight:700;
line-height:1.4;
overflow-wrap:anywhere;
}
.tn-wpm-recs__price {
margin:auto 0 22px;
color:var(--tn-green);
font-size:24px;
font-weight:700;
letter-spacing:-.5px;
}
.tn-wpm-recs__cta {
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding-top:16px;
border-top:1px solid #e7ece5;
color:var(--tn-green);
font-size:13px;
font-weight:700;
}
.tn-wpm-recs__arrow {
display:flex;
align-items:center;
justify-content:center;
width:32px;
height:32px;
border-radius:50%;
background:var(--tn-green);
color:#fff;
font-size:18px;
}
@media(max-width:900px) {
.tn-wpm-recs__card { flex-basis:calc((100% - 24px) / 2); }
}
@media(max-width:600px) {
.tn-wpm-recs { padding:24px 0; }
.tn-wpm-recs__heading { align-items:flex-start; flex-direction:column; gap:12px; }
.tn-wpm-recs__track { gap:16px; }
.tn-wpm-recs__card { flex-basis:88%; }
.tn-wpm-recs__body { padding:20px; }
.tn-wpm-recs__name { font-size:18px; }
}
@media(prefers-reduced-motion:reduce) {
.tn-wpm-recs__link { transition:none; }
.tn-wpm-recs__link:hover { transform:none; }
}
</style>
<section class="tn-wpm-recs" aria-label="TrailNest product recommendations">
<div class="tn-wpm-recs__heading">
<div>
<p class="tn-wpm-recs__eyebrow">TRAILNEST · DISCOVER MORE</p>
<h2 class="tn-wpm-recs__title">Recommended for you</h2>
</div>
<p class="tn-wpm-recs__hint">Scroll to explore <span aria-hidden="true">→</span></p>
</div>
<div class="tn-wpm-recs__track" role="list" tabindex="0" aria-label="Recommended products. Scroll horizontally to see more.">
{{#each data}}
<div class="tn-wpm-recs__card" role="listitem">
<a class="tn-wpm-recs__link" href="{{subVar 'url'}}">
<div class="tn-wpm-recs__visual">
<img class="tn-wpm-recs__image" src="{{subVar 'image'}}" alt="" loading="lazy" decoding="async" />
</div>
<div class="tn-wpm-recs__body">
<h3 class="tn-wpm-recs__name">{{subVar 'name'}}</h3>
<p class="tn-wpm-recs__price">¥ {{subVar 'price'}}</p>
<div class="tn-wpm-recs__cta">
<span>Explore this gear</span>
<span class="tn-wpm-recs__arrow" aria-hidden="true">→</span>
</div>
</div>
</a>
</div>
{{/each}}
</div>
</section>
Save & Activate で保存したら、商品詳細ページを再読み込みします。

ここまでで、商品カードのデザインが変わり、金額の前に ¥ が表示されます。
PC では最初の3商品が並び、4商品目は横スクロールすると表示されます。4商品目を削除したり、非表示に固定したりしているわけではありません。 スマートフォンでは、左右にスワイプして商品を確認できます。
今回は、スクロールバーやスワイプで操作するカルーセルです。自動で切り替わる機能や、左右の送りボタンは追加していません。
円記号の変更と、小数点の整形は別の処理です
このテンプレートでは、{{subVar 'price'}} が返す値をそのまま表示しています。そのため、商品データの価格が 12800.0 の場合は、¥ 12800.0 と表示されます。
小数点以下を消し、¥12,800 のように3桁ごとのカンマも付けたい場合は、次の手順を追加します。
⑩ CloudPages で金額の小数点と桁区切りを整える
前の手順では、Experience Template で 通貨記号とデザインを変更しました。ここでは、金額を整数で表示し、3 桁ごとのカンマを付ける処理を追加します。
例えば、次のように表示を整えます。
¥ 12800.0 → ¥12,800
¥ 5600.0 → ¥5,600
¥ 0.0 → ¥0
今回は、CloudPages 側に JavaScript を追加し、WPM が商品カードを表示した後に、その金額を整形します。
WPM の商品カードはページの読み込み後に追加されるため、ページを開いたときに1回だけ処理するのではなく、後から追加された商品カードにも対応する構成にします。
CloudPages に価格を整形するコードを追加する
TrailNest の CloudPages の HTML を開き、末尾にある </body> の直前に、次のコード全体を追加します。
このコードは CloudPages に追加します。
<script>
(() => {
if (window.__tnWpmYenFormatter) return;
window.__tnWpmYenFormatter = true;
const formatter = new Intl.NumberFormat("ja-JP", {
style: "currency",
currency: "JPY",
minimumFractionDigits: 0,
maximumFractionDigits: 0
});
function formatPrices() {
document.querySelectorAll(".tn-wpm-recs__price").forEach(element => {
const raw = element.textContent.trim()
.replace(/^[¥¥]\s*/, "")
.replace(/,/g, "");
if (!/^-?\d+(?:\.\d+)?$/.test(raw)) return;
const amount = Number(raw);
if (!Number.isFinite(amount)) return;
const formatted = formatter.format(amount);
if (element.textContent !== formatted) {
element.textContent = formatted;
}
});
}
function start() {
formatPrices();
const observer = new MutationObserver(formatPrices);
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
start();
}
})();
</script>
この処理は、今回の Experience Template の .tn-wpm-recs__price に対して適用します。トップページや注文完了ページの、既存の API 用商品カードには適用しません。
Intl.NumberFormat で日本円として表示し、minimumFractionDigits と maximumFractionDigits をともに 0 にすることで、小数点以下を表示しないようにしています。
商品データにゼロ以外の小数部分がある場合は、整数に四捨五入します。例えば 1234.56 は ¥1,235 と表示されます。商品データそのものや、注文時の金額を変更する処理ではありません。
公開して表示を確認する
CloudPages を保存・公開し、商品詳細ページを再読み込みします。

金額の前に円記号が表示される
小数点以下が表示されない
3桁ごとのカンマが付く
WPM のプレビューで商品が切り替わった場合も、同じ金額形式で表示される
PC とスマートフォンで、横スクロールやスワイプによる商品表示が動作する
ここまでの設定を通じて、Experience Template が「返されたデータを、どのように見せるか」を担うものだと、少しずつイメージできてきたのではないでしょうか。
標準テンプレートを出発点に、Vibe Coding でデザインを変更していく方法もあります。「カルーセルにしたい」「TrailNest らしい色にしたい」など、実現したいことを生成 AI に伝えて、ぜひ試してみてください。
既存のテンプレートを活用するのも良いですが、自由な発想で、思い付いたアイデアを気軽に生成 AI に作ってもらうのも面白いです。最初から完成形を決めず、表示を確認しながら試していくことで、想像していた以上のデザインに出会えることもあります。
いかがでしたでしょうか。
今回は、顧客の興味が最も強いカテゴリの中から、過去 2 週間によく見られた商品を選び、Experience Template と WPM で商品詳細ページへ表示する構成を紹介しました。
トップページと注文完了ページでは API、商品詳細ページでは WPM と、ページごとに表示方法を分けられます。商品詳細の表示を WPM に任せることで、商品カードのテンプレートと配置場所を Personalization 側で管理できます。
今後は、今回のレコメンド用 Personalization Point を Agentforce から呼び出し、チャットにも画像付きの商品カードを表示する構成へ進みます。
なお、今回の構成では、表示中のカルーセルがリアルタイムで勝手に並び替わることはありません。
2つの変更を分けて考えると分かりやすいです。
① おすすめの「カテゴリ」が変わるタイミング
顧客の行動で Affinity が更新され、最高スコアのカテゴリが変わると、次の Personalization Request で、そのカテゴリの商品を選びます。Affinity はリアルタイムの Profile Data Graph を使う構成です。
② オススメの「商品の順番」が変わるタイミング
全ユーザーの 過去 14 日間の閲覧回数です。次の処理を経て変わります。
商品閲覧イベントが Data 360 に取り込まれます。
Calculated Insight がスケジュールに従って再集計されます。今回のスケジュールは「Daily」にしました。
さらにその更新結果が Item Data Graph に反映される必要があります。こちらも「Daily」の処理です。
次のレコメンド取得で、更新された閲覧回数順の商品が返ります。
このランキングは定期集計を使うため、商品を1回見た直後に順位が変わるとは限りません。気になるところかと思いますので、参考までに。
今回は以上です。