
【第626回】 Real-Time Offer Management : クーポンを適用してカートの割引額を計算する
前回の記事では、TrailNest の商品閲覧に合わせて、Web サイトに表示するオファーを切り替えました。
今回は、その特典をカートの金額に反映します。
テントをカートに追加すると、Salesforce が適用条件を確認し、購入金額に応じた特典を自動適用します。さらに、クーポンコードを入力すると、対象商品の割引も適用されます。

今回確認する動きは、次のとおりです。
クーポン入力前:$234.00
テント $249 に、Weekend の特典として $15 引きが自動適用されます。テントのクーポン適用後:$209.10
テントの 10% 割引($24.90)と、Weekend の $15 引きが適用されます。クーポン解除後:$234.00
テントのクーポンによる割引が解除され、Weekend の $15 引きは残ります。
カートの内容とクーポンの有無に応じて、Salesforce がその場で割引後の金額を計算するところまで実装します。
Real-Time Offer Management シリーズ
今回の仕組み
今回、役割が分かれるのは次の 2 点です。
Salesforce Personalization:顧客に案内するオファーを選ぶ
Global Promotions Management:カートに割引を適用できるかを判断し、金額を計算する
例えば、テントのオファーが表示されたからといって、寝袋だけを入れたカートにテントの割引を適用できるわけではありません。
実際にカートへ入っている商品と、入力されたクーポンを使って、改めて適用条件を確認します。
この計算には、Promotion Evaluation and Execution API を使用します。現在のカート情報を渡し、割引後の金額を取得する仕組みです。
① テントの Promotion にクーポンを設定する
以前に作成した TrailNest Tent Savings の Promotion を開き、Overview タブで「無効化」します。無効化しないとクーポンが追加できません。

無効化できたら、Edit をクリックします。

遷移後の画面で、前に戻るボタンを数回クリックして、Define Promotion Eligibility のセクションで、クーポンを追加します。
Do you want to add coupons to the promotion?:Yes
New Coupon から、今回のクーポンを用意します。
Coupon Code:TN-TENTS-10
Status:Active
Start Date Time:開始日時
End Date Time:終了日時

※ 今回は設定しませんが、クーポン全体の利用回数の上限や 1 人当たりの利用回数の上限もここで設定できます。
設定を保存したら、Promotion を Active に戻します。
また、今回の動作確認では、テントのクーポンである TN-TENTS-10 のみ設定して検証します。
② Pricing Procedure で割引の計算処理を用意する
続いて、そのクーポンのルールを使い、カートの金額を実際に計算するための Pricing Procedure を作成します。

役割は、次のように分かれます。
Promotion:どの条件で、どれだけ割引するか。
Pricing Procedure:商品ごとの割引やカート全体の割引を、どの順番で計算するか。
① Pricing Procedure を作成する
アプリランチャーから Pricing Procedures を開き、New をクリックします。

次の内容で作成します。
Name:TrailNest Cart Pricing
API Name:TrailNestCartPricing
Usage Type:Pricing
Usage Subtype:Global Promotions Management
Context Definition:TrailNestPromotionContext
Context Definition には、初期設定の記事で作成したもの を指定します。

保存後、作成された Pricing Procedure Version をクリックすると、Pricing Procedure Builder が開きます。

Pricing Procedure Properties → Advanced Details では、次の値を指定します。
Rank:1

② 計算で使用する固定値を用意する
カート全体の割引を各商品へ配分するため、次の Constant Resource(定数) を用意します。

1. DiscountType
Resource Type:Constant
Data Type:Text
Value:Amount
2. DistributionLogic
Resource Type:Constant
Data Type:Text
Value:Proportionate
3. DistributionType
Resource Type:Constant
Data Type:Text
Value:ItemNetTotalPrice

③ 計算処理を順番に配置する
Pricing Procedure Builder では、次の順にステップを組み、処理を配置します。
1. Pricing Setting Element

商品明細の ID、単価、金額を、計算処理で使用できるようにします。
入力
Line Item:cartLineItemId
出力
Net Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount

2. GPM Promotion Execution:Line
商品明細単位のプロモーションを評価します。
Rule Library:TrailNest Promotions
Promotion Scope:Line
※ Rule Library はテキストをコピペするのではなく、呼び出しを行ってください。テキストのコピペだけだと、保存をするときに RuleLibrary isn't a valid variable name. Specify a valid variable name. のエラーが出ます。

入力
Quantity:cartLineItemQuantity
Input Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount
出力
Net Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount

3. GPM Promotion Execution:Crossline
複数の商品明細にまたがるプロモーションを評価します。
Rule Library:TrailNest Promotions
Promotion Scope:Crossline
入力
Quantity:cartLineItemQuantity
Input Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount
出力
Net Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount

4. Aggregate Price
ここまでの計算を終えた商品明細の金額を合計します。
Formula:SUM(cartLineItemAmount)
Output Variable:initialTransactionAmount_std
※ この値が、カート全体の割引を適用する前の金額になります。

5. GPM Promotion Execution:Order
カート全体を対象とするプロモーションを評価します。
Rule Library:TrailNest Promotions
Promotion Scope:Order
入力
Header Id:cartHeaderId_std
Header Subtotal:initialTransactionAmount_std
出力
Header Subtotal:transactionAmount

6. Formula Based Pricing
カート全体の割引を適用する前後の差額を計算します。
Formula
initialTransactionAmount__std - transactionAmount
Output Variable
cartLevelDiscountValue_std

7. Discount Distribution Service
カート全体に適用した割引額を、商品明細へ配分します。
入力
Header Discount Type:DiscountType
Header Discount Value:cartLevelDiscountValue_std
Header Distribution Logic:DistributionLogic
Header Distribution Type:DistributionType
List Price:cartLineNetUnitPrice_std
Quantity:cartLineItemQuantity
Line Item:cartLineItemId
Input Unit Price:cartLineNetUnitPrice_std
Header Subtotal:initialTransactionAmount_std
出力
Discount Value:cartLineDiscountFromCartDistribution_std
Distribution Type:DistributionType
Discount Type:DiscountType
Net Unit Price:cartLineNetUnitPrice_std
Subtotal:cartLineItemAmount

④ 保存して有効化する
配置順と各項目の設定を確認し、保存します。

有効化します。

⑤ TrailNest の Parent Group に紐付ける
アプリケーションランチャーから「Promotion Console」 → Configure Evaluation and Execution Method を開きます。

TrailNest Promotions で、Pricing Procedure の編集をクリックします。

上で作成した Pricing Procedure を選択します。

続いて Configure を開きます。

今回の検証では各グループの評価方法を Highest Discount にします。
Line Rewards
Cross Line Rewards
Cart Rewards


これは、それぞれのグループで「割引額が最も大きいものを選ぶ」設定です。カート全体で割引が必ず 1 つになる、という意味ではありません。
これで、CloudPages から送られたカート情報に対し、TrailNest のプロモーションを使って割引を計算する準備が整います。実際の適用結果は、続くカートの接続テストで確認します。
③ Salesforce への接続を準備する
前回のオファー表示では、認証不要にした Personalization Point をブラウザから呼び出しました。
今回の割引計算には、Salesforce への認証が必要です。
そのため、CloudPages のサーバー側で Salesforce に接続し、計算結果だけをブラウザに返します。
API を実行するユーザーの権限
今回の接続で使用するユーザーに、次の権限を確認します。
API Enabled(システム権限)
Promotion Designer Basic(権限セット)
Promotion Designer Extension(権限セット)
Context Service Runtime(権限セット)
Salesforce Pricing Run Time User(権限セット)
External Client App を作成する
設定から External Client App Manager を開き、新規作成します。
External Client App Name:TrailNest Cart API
API Name:TrailNest_Cart_API
Contact Email:管理用のメールアドレス
OAuth を有効にし、次を設定します。
OAuth Scope:Manage user data via APIs(api)
Enable Client Credentials Flow:有効
Callback URL が必須の場合は、次の値を設定します。この接続方式では、ブラウザを Callback URL へ戻す処理は使用しません。
https://login.salesforce.com/services/oauth2/success

作成後、Policies の Client Credentials Flow の設定で、Run As に先ほどの上の権限セットを持った実行ユーザーを指定します。
最後に、次の 2 つを控えます。
Consumer Key
Consumer Secret
④ 計算用の CloudPages を作成する
次に、計算用のページを用意します。
CloudPages のコードリソースに、新しい空の HTML ページを作成します。
※ Landing Page ではなく Code Resource を選択してください。
リソース名:TrailNest Cart API
レイアウト:Javascript
以下の全体を貼り付けます。
<script runat="server">
Platform.Load("Core", "1");
/* SERVER SIDE ONLY. Paste the entire file into a separate CloudPages HTML page.
* Never copy the values below into browser JavaScript, screenshots, or articles.
* This demo quotes prices only. It does not place orders or consume coupons.
*/
var config = {
salesforceOrigin: "https://YOUR_MY_DOMAIN.my.salesforce.com",
clientId: "YOUR_CONSUMER_KEY",
clientSecret: "YOUR_CONSUMER_SECRET",
testContactId: "YOUR_TEST_CONTACT_ID",
siteOrigin: "https://mcxkknz3m0zj3jqsxn4hm3dp3q-4.pub.sfmc-content.com",
ruleLibrary: "TrailNest_Promotions"
};
(function () {
Platform.Response.SetResponseHeader("Content-Type", "application/json; charset=utf-8");
Platform.Response.SetResponseHeader("Cache-Control", "no-store");
Platform.Response.SetResponseHeader("X-Content-Type-Options", "nosniff");
var catalog = {
"TN-TE-002": { id: "01tWs00000HJotlIAD", price: 249 },
"TN-SL-001": { id: "01tWs00000HJovNIAT", price: 199 }
};
var stage = "REQUEST";
var httpStatus = null;
var upstreamCode = null;
function fail(code) { var error = new Error(code); error.demoCode = code; throw error; }
function array(value) { return Object.prototype.toString.call(value) === "[object Array]"; }
function cents(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") fail("INVALID_TOTALS");
var number = Number(value);
if (!isFinite(number) || number < 0) fail("INVALID_TOTALS");
return Math.round(number * 100);
}
function post(url, body, type, token) {
var request = new Script.Util.HttpRequest(url);
request.method = "POST";
request.contentType = type;
request.encoding = "UTF-8";
request.postData = body;
request.retries = 0;
request.continueOnError = true;
request.setHeader("Accept", "application/json");
if (token) request.setHeader("Authorization", "Bearer " + token);
var response = request.send();
httpStatus = Number(response.statusCode);
var parsed;
try { parsed = Platform.Function.ParseJSON(String(response.content)); }
catch (ignore) { fail("NON_JSON_RESPONSE"); }
if (httpStatus < 200 || httpStatus >= 300) {
var item = array(parsed) ? parsed[0] : parsed;
var candidate = item && (item.errorCode || item.error);
if (typeof candidate === "string" && /^[A-Za-z0-9_]{1,80}$/.test(candidate)) upstreamCode = candidate;
fail("UPSTREAM_ERROR");
}
return parsed;
}
function utcNow() {
var date = new Date();
function pad(n) { return n < 10 ? "0" + n : String(n); }
return date.getUTCFullYear() + "-" + pad(date.getUTCMonth()+1) + "-" + pad(date.getUTCDate()) + "T" + pad(date.getUTCHours()) + ":" + pad(date.getUTCMinutes()) + ":" + pad(date.getUTCSeconds()) + "Z";
}
try {
if (String(Platform.Request.Method).toUpperCase() !== "POST") fail("POST_REQUIRED");
// Browser-origin restriction, not user authentication. No cross-origin API use.
if (String(Platform.Request.GetRequestHeader("Origin") || "") !== config.siteOrigin) fail("ORIGIN_NOT_ALLOWED");
var raw = String(Platform.Request.GetPostData("UTF-8") || "");
if (!raw || raw.length > 4096) fail("INVALID_REQUEST");
var input;
try { input = Platform.Function.ParseJSON(raw); } catch (ignore) { fail("INVALID_JSON"); }
if (!input || !array(input.items) || input.items.length < 1 || input.items.length > 2) fail("INVALID_ITEMS");
if (typeof input.cartId !== "string" || !/^TN-[A-Za-z0-9-]{8,70}$/.test(input.cartId)) fail("INVALID_CART_ID");
var coupon = typeof input.coupon === "string" ? input.coupon.replace(/^\s+|\s+$/g, "").toUpperCase() : "";
// Only the three demo coupons can reach Salesforce through this endpoint.
if (coupon && coupon !== "TN-TENTS-10" && coupon !== "TN-SLEEPING-15" && coupon !== "TN-WEEKEND-15") fail("UNSUPPORTED_COUPON");
var subtotal = 0;
var lines = [];
var seen = {};
for (var i = 0; i < input.items.length; i++) {
var item = input.items[i];
if (!item || (item.code !== "TN-TE-002" && item.code !== "TN-SL-001") || seen[item.code]) fail("INVALID_PRODUCT");
if (typeof item.quantity !== "number" || item.quantity !== Math.floor(item.quantity) || item.quantity < 1 || item.quantity > 99) fail("INVALID_QUANTITY");
seen[item.code] = true;
var product = catalog[item.code];
var amount = product.price * item.quantity;
subtotal += amount;
lines.push({
cartLineItemId: "TN-LINE-" + item.code,
cartLineProductId: product.id,
cartLineItemQuantity: item.quantity,
cartLineItemAmount: amount,
cartLineNetUnitPrice__std: product.price
});
}
stage = "CONFIG";
if (!/^https:\/\/[a-z0-9-]+\.my\.salesforce\.com$/i.test(config.salesforceOrigin) || config.salesforceOrigin.indexOf("YOUR_") >= 0) fail("CHECK_MY_DOMAIN");
if (!/^003[A-Za-z0-9]{12}([A-Za-z0-9]{3})?$/.test(config.testContactId)) fail("CHECK_CONTACT_ID");
if (config.clientId.indexOf("YOUR_") === 0 || config.clientSecret.indexOf("YOUR_") === 0) fail("CHECK_OAUTH_CREDENTIALS");
var cart = {
cartHeaderId__std: input.cartId,
activityStartDate: utcNow(),
contactId: config.testContactId,
transactionAmount: subtotal,
currencyISOCode: "USD",
cartLineDetails: lines
};
if (coupon) cart.cartPromotionAdditionalInformation__std = [{ cartCouponCode__std: coupon }];
stage = "AUTH";
var auth = post(config.salesforceOrigin + "/services/oauth2/token",
"grant_type=client_credentials&client_id=" + encodeURIComponent(config.clientId) + "&client_secret=" + encodeURIComponent(config.clientSecret),
"application/x-www-form-urlencoded", null);
if (!auth.access_token) fail("TOKEN_MISSING");
stage = "PRICING";
var result = post(config.salesforceOrigin + "/services/data/v65.0/global-promotions-management/promotion-execution/?ruleLibraryApiName=" + encodeURIComponent(config.ruleLibrary),
Stringify({ cart: { cartDetails: [cart] } }), "application/json", auth.access_token);
stage = "RESPONSE";
// Support the Cart response and an explicit cart wrapper; never search arbitrary data.
var details = result && (result.cartDetails || (result.cart && result.cart.cartDetails));
if (!array(details) || details.length !== 1) fail("CART_RESPONSE_MISSING");
var quote = details[0];
if (quote.cartHeaderId && quote.cartHeaderId !== input.cartId) fail("CART_RESPONSE_MISMATCH");
var initial = cents(quote.initialCartAmount);
var discount = cents(quote.totalDiscountAmount);
var total = cents(quote.adjustedCartAmount);
if (initial !== Math.round(subtotal * 100) || discount > initial || Math.abs(initial - discount - total) > 1) fail("TOTALS_MISMATCH");
Write(Stringify({ ok: true, currency: "USD", subtotal: initial / 100, discount: discount / 100, total: total / 100, coupon: coupon }));
} catch (error) {
// No token, secret, Contact ID, or raw Salesforce response is exposed.
Write(Stringify({ ok: false, stage: stage, code: error.demoCode || "REQUEST_FAILED", upstreamStatus: httpStatus, upstreamCode: upstreamCode }));
}
})();
</script>
先頭の設定を変更する
ファイルの先頭にある、次の項目を変更します。
salesforceOrigin
今回の RTOM 組織の My Domain URL。
例:https://YOUR_MY_DOMAIN.my.salesforce.comclientId
External Client App の Consumer Key。clientSecret
External Client App の Consumer Secret。testContactId
今回の組織に存在する、テスト顧客の Contact ID。003 で始まる値です。siteOrigin
TrailNest の公開 URL の、https:// からドメインまで。ページのパスや末尾の / は含めません。ruleLibrary
今回は TrailNest_Promotions のままです。
設定後、ページを公開し、公開 URL を控えます。
⑤ TrailNest のカートを更新する
続いて、既存の TrailNest の CloudPages を開き、以下に変更します。
<!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;
}
}
.tn-coupon {margin:28px 0; padding:24px; border-radius:18px; background:#f3f5ed;}
.tn-coupon-controls {display:flex; flex-wrap:wrap; gap:10px;}
.tn-coupon-controls input {flex:1; min-width:160px; width:100%; padding:12px; border:1px solid #b4c1b3; border-radius:10px; background:white; text-transform:uppercase;}
.tn-totals {margin:24px 0 12px;}
.tn-totals > div {display:flex; justify-content:space-between; gap:20px; padding:8px 0;}
.tn-totals dd {margin:0; font-weight:700;}
.tn-totals .tn-total {border-top:1px solid #cbd6c5; margin-top:10px; padding-top:16px; font-size:26px;}
@media(max-width:640px) {.tn-coupon {padding:18px;} .tn-coupon-controls input {flex-basis:100%;}}
</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',
// Published URL of the separate server-side CloudPages page (same origin).
cartEndpoint: 'YOUR_CART_API_CLOUDPAGES_URL',
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();
}
// Cart quotes are independent of tracking consent and the Personalization SDK.
let selectedCoupon = '';
let quoteVersion = 0;
let quoteController = null;
let cartId = getStored('trailnest-quote-cart-id-v1', '');
if (typeof cartId !== 'string' || !/^TN-[A-Za-z0-9-]{8,70}$/.test(cartId)) {
cartId = 'TN-' + (crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
setStored('trailnest-quote-cart-id-v1', cartId);
}
/*
* 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() {
quoteVersion++;
if (quoteController) quoteController.abort();
const rows = C.products.filter(product => cart[product.code]);
const subtotal = rows.reduce((total, product) => 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 item</button>
</div>
`).join('') : '<p>Your bag is ready for an adventure.</p>'}
${rows.length ? `
<div class="tn-coupon">
<form id="coupon-form">
<label for="coupon-code">Have an offer code?</label>
<div class="tn-coupon-controls">
<input id="coupon-code" name="coupon" maxlength="40" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="Enter your code" value="${escape(selectedCoupon)}">
<button id="coupon-apply" type="submit">Apply</button>
<button id="coupon-remove" type="button" class="secondary">Remove code</button>
</div>
</form>
<p id="coupon-status" class="status" role="status" aria-live="polite">Checking your bag…</p>
<dl class="tn-totals" aria-label="Bag totals">
<div><dt>Subtotal</dt><dd id="quote-subtotal">${money(subtotal)}</dd></div>
<div><dt>Discount</dt><dd id="quote-discount">—</dd></div>
<div class="tn-total"><dt>Total</dt><dd id="quote-total">—</dd></div>
</dl>
<p class="muted">USD · Demo quote only. Tax, shipping and checkout are not included.</p>
</div>
` : ''}
<a href="?">Continue exploring →</a>
</section>`;
if (rows.length) void quoteCart(selectedCoupon);
else selectedCoupon = '';
}
async function quoteCart(code) {
const version = ++quoteVersion;
if (quoteController) quoteController.abort();
const controller = new AbortController();
quoteController = controller;
const timer = setTimeout(() => controller.abort(), 70000);
const button = $('coupon-apply');
const remove = $('coupon-remove');
const input = $('coupon-code');
const status = $('coupon-status');
if (!status) { clearTimeout(timer); return; }
button.disabled = true;
remove.disabled = true;
input.disabled = true;
selectedCoupon = code.trim().toUpperCase();
input.value = selectedCoupon;
// Clear any old quote immediately. A failed request must not look like a valid total.
$('quote-total').textContent = '—';
$('quote-discount').textContent = '—';
status.textContent = 'Calculating your offer…';
try {
const endpoint = new URL(C.cartEndpoint);
if (endpoint.origin !== location.origin || endpoint.protocol !== 'https:') {
throw new Error('CART_ENDPOINT: Use the published API page on the same domain.');
}
const items = C.products.filter(product => cart[product.code]).map(product => ({code: product.code, quantity: cart[product.code]}));
const response = await fetch(endpoint.href, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
cache: 'no-store',
signal: controller.signal,
body: JSON.stringify({cartId, coupon: selectedCoupon, items})
});
if (!response.ok) throw new Error('CLOUDPAGES_HTTP_' + response.status);
let quote;
try { quote = await response.json(); }
catch { throw new Error('CLOUDPAGES_NON_JSON: Check the server-side page.'); }
if (!quote.ok) throw new Error([quote.stage, quote.code, quote.upstreamStatus, quote.upstreamCode].filter(Boolean).join(' / '));
if (quote.currency !== 'USD' || ![quote.subtotal, quote.discount, quote.total].every(v => typeof v === 'number' && Number.isFinite(v) && v >= 0)) throw new Error('INVALID_TOTALS');
if (version !== quoteVersion) return;
$('quote-subtotal').textContent = money(quote.subtotal);
$('quote-discount').textContent = quote.discount ? '−' + money(quote.discount) : money(0);
$('quote-total').textContent = money(quote.total);
status.textContent = selectedCoupon
? (quote.discount > 0 ? 'Your bag has been recalculated with the offer code.' : 'This code did not produce a discount for your bag.')
: (quote.discount > 0 ? 'An automatic promotion applies to your bag.' : 'No offer code applied. Your bag is up to date.');
} catch (error) {
if (version !== quoteVersion) return;
status.textContent = 'We could not calculate this offer. Please check the code and try again.';
console.warn('[TrailNest cart]', error.message);
if ($('debug-output')) $('debug-output').textContent = '[Cart] ' + error.message;
} finally {
clearTimeout(timer);
if (version === quoteVersion) {
button.disabled = false;
remove.disabled = false;
input.disabled = false;
}
}
}
content.addEventListener('submit', event => {
if (event.target.id !== 'coupon-form') return;
event.preventDefault();
void quoteCart($('coupon-code').value);
});
content.addEventListener('click', event => {
if (event.target.closest('#coupon-remove')) void quoteCart('');
});
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>
<script src="https://cdn.c360a.salesforce.com/beacon/c360a/5404ac93-0fce-4bc1-9ac5-c39771369434/scripts/c360a.min.js"></script>
</body>
</html>変更する主な設定は、次の 2 つです。
endpoint
前回使用している、Data 360 の Decision API URL を設定します。
endpoint:
'https://YOUR_DATA_360_TENANT.c360a.salesforce.com/personalization/decisions',cartEndpoint
先ほど公開した、TrailNest Cart API の公開 URL を設定します。
cartEndpoint: 'YOUR_CART_API_CLOUDPAGES_URL',設定後、再公開します。
⑥ カートで適用・解除を確認する
ここからは、公開した TrailNest 上で確認します。
Enable personalized offers を有効化して、メールアドレスを送信します。
※ 上でソースに埋め込んだ Contact Id のメールアドレスです。

テントを 1 点追加する
Trail Duo Tent の商品詳細ページを開き、Add to bag をクリックします。

続いて、ヘッダーの Bag からカートへ移動します。

他の商品が入っている場合は削除し、テント 1 点だけにします。
今回は、Weekend の特典をクーポン不要の自動適用にしています。テントの価格は $249 のため、「$150 以上で $15 引き」の条件を満たします。
最初は、次の金額になることを確認します。
Subtotal:$249.00
Discount:−$15.00
Total:$234.00
クーポンを入力していなくても、Salesforce がカートの金額を確認し、条件を満たす特典を適用しています。

クーポンを適用する
入力欄に、次のコードを入力します。
TN-TENTS-10
Apply をクリックします。

Salesforce での計算が完了したら、テントの 10% 割引が適用され、金額が更新されることを確認します。
もしここで金額が更新されない場合は Promotion が Active になっているかを確認してください。

今回の設定で、テントの 10% 割引と Weekend の $15 引きが併用される場合は、次の金額になります。
Subtotal:$249.00
Discount:−$39.90
Total:$209.10

Tips:ここでの割引の併用可否は、プロモーショングループの設定に従います。上記は、テントの 10% 割引($24.90)と Weekend の $15 引きが両方適用される場合の金額です。
クーポンを解除する
続いて、Remove code をクリックします。
クーポンコードを含めずに Salesforce へ再計算を依頼します。
テントのクーポンによる割引は解除されますが、Weekend の自動適用条件は引き続き満たしているため、$15 引きは残ります。
Subtotal:$249.00
Discount:−$15.00
Total:$234.00

いかがでしたでしょうか。
今回は、TrailNest のカートにクーポン入力欄を追加し、Salesforce が計算した割引後の金額を表示する構成を用意しました。
前回は、顧客に合わせて「どの特典を案内するか」を選びました。
今回は、その特典を利用する際に、実際のカートに適用できるかを確認し、金額へ反映するところまでつなげています。
クーポンの適用・解除やカートの変更に応じて、その時点の内容を再計算する。これが、今回確認するリアルタイムの動作です。
なお、今回のデモは金額の再計算までです。注文の確定、決済、クーポン利用回数の更新は含めていません。
今回は以上です。