メインコンテンツへスキップ
見出し画像
Photo bykomo_analysis

【第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.com

    • clientId
      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&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',
    
      // 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 => ({
          '&': '&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();
      }
    
      // 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&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() {
        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&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 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 が計算した割引後の金額を表示する構成を用意しました。

    前回は、顧客に合わせて「どの特典を案内するか」を選びました。

    今回は、その特典を利用する際に、実際のカートに適用できるかを確認し、金額へ反映するところまでつなげています。

    クーポンの適用・解除やカートの変更に応じて、その時点の内容を再計算する。これが、今回確認するリアルタイムの動作です。

    なお、今回のデモは金額の再計算までです。注文の確定、決済、クーポン利用回数の更新は含めていません。

    今回は以上です。


    次の記事はこちら

    前回の記事はこちら

    私の note のトップページはこちら

     
     
    Salesforce Marketing Cloud、Agentforce、Data Cloud、Salesforce 認定資格に関する実践的な情報を発信しています。これらの記事が、皆さまの学習や日々の業務に少しでもお役に立てば幸いです。