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

【第625回】 Real-Time Offer Management : 顧客に合うオファーを Web サイトに表示する

    前回の記事では、顧客が閲覧した商品のカテゴリに合わせて、案内するオファーを選ぶ Recommender を作成しました。

    今回は、その結果を Web サイトに表示します。

    TrailNest の商品詳細ページを開くと、商品とカテゴリの情報が Data 360 に送信されます。Personalization は、その顧客の閲覧履歴を参照して、案内するオファーを選びます。

    さらに、メールアドレスを入力する欄も用意し、前の記事で設定した ID 解決につなげます。

    今回は、商品閲覧の送信から、選ばれたオファーの Web 表示までを確認します。


    Real-Time Offer Management シリーズ


    今回作成する Web サイト

    今回は、Real-Time Offer Management の検証用として、TrailNest の Web サイトを用意しました。

    画像

    表示している商品は、これまで設定してきた次の 2 つのみです。

    • Trail Duo Tent

    • Alpine Down Sleeping Bag

    Web サイトには、次の画面と機能を用意します。

    • トップページ

    • カテゴリページ

    • 商品詳細ページ

    • オファーの表示領域

    • テスト顧客のメールアドレス入力欄

    • 商品を追加できるカート画面

    カートには、商品追加と小計表示まで用意します。クーポンによる割引計算と注文処理は、今回の実装には含めません。

    まずは、商品閲覧によって案内する特典が変わることを確認します。


    ① CloudPages に HTML を配置する

    今回の Real-Time Offer Management 用として用意した CloudPages を開き、以下を CloudPages の HTML に貼り付けます。

    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>TrailNest — A little closer to the wild</title>
    
    <style>
    :root {
      --green:#183b2c;
      --paper:#f6f4ec;
      --ink:#20392c;
      --line:#dce3d8;
      --accent:#e5b66b;
    }
    * { box-sizing:border-box; }
    body {
      margin:0;
      background:var(--paper);
      color:var(--ink);
      font:16px/1.6 Arial,Helvetica,sans-serif;
    }
    button,input { font:inherit; }
    a { color:inherit; }
    
    button,.button {
      display:inline-flex;
      align-items:center;
      justify-content:center;
      border:0;
      border-radius:999px;
      padding:12px 22px;
      background:var(--green);
      color:#fff;
      text-decoration:none;
      cursor:pointer;
      font-weight:700;
    }
    button:disabled { opacity:.5; cursor:wait; }
    
    a:focus-visible,
    button:focus-visible,
    input:focus-visible {
      outline:3px solid #b17c37;
      outline-offset:4px;
    }
    
    [hidden] { display:none!important; }
    
    header {
      background:#fff;
      border-bottom:1px solid var(--line);
    }
    nav {
      max-width:1120px;
      margin:auto;
      padding:20px 24px;
      display:flex;
      align-items:center;
      gap:22px;
    }
    .brand {
      font-size:25px;
      font-weight:800;
      letter-spacing:-1px;
      text-decoration:none;
    }
    .nav-links {
      display:flex;
      gap:20px;
      margin-left:auto;
    }
    .cart-link {
      white-space:nowrap;
      font-weight:700;
    }
    main {
      max-width:1120px;
      margin:auto;
      padding:28px 24px 60px;
    }
    .hero {
      padding:64px 40px;
      border-radius:28px;
      background:
        linear-gradient(
          90deg,
          rgba(17,45,31,.92),
          rgba(17,45,31,.3)
        ),
        url('https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/Default_Hero.png')
        center/cover;
      color:white;
    }
    .eyebrow {
      text-transform:uppercase;
      letter-spacing:2px;
      font-size:12px;
      font-weight:700;
    }
    .hero h1 {
      max-width:650px;
      font-size:clamp(36px,6vw,64px);
      line-height:1.06;
      margin:18px 0;
    }
    .hero p { max-width:480px; }
    .hero .button {
      background:var(--accent);
      color:var(--green);
    }
    .section-head {
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:16px;
      margin:32px 0 18px;
    }
    h2 {
      font-size:28px;
      line-height:1.2;
    }
    .grid {
      display:grid;
      grid-template-columns:repeat(2,minmax(0,1fr));
      gap:24px;
    }
    .card,.panel {
      background:#fff;
      border:1px solid var(--line);
      border-radius:22px;
      overflow:hidden;
    }
    .card a { text-decoration:none; }
    .card img {
      display:block;
      width:100%;
      height:260px;
      object-fit:contain;
      background:#edf0e7;
      padding:20px;
    }
    .card-body,.panel { padding:26px; }
    .card h3 {
      font-size:24px;
      margin:8px 0;
    }
    .price {
      font-size:25px;
      font-weight:700;
    }
    .detail {
      display:grid;
      grid-template-columns:1fr 1fr;
      gap:36px;
      align-items:center;
    }
    .detail img {
      width:100%;
      height:420px;
      object-fit:contain;
      background:#edf0e7;
      border-radius:24px;
    }
    .detail h1 {
      font-size:40px;
      line-height:1.15;
    }
    .muted {
      color:#647468;
      font-size:14px;
    }
    .offer-area { margin:32px 0; }
    .offer-area h2 { margin:0; }
    .offer-body {
      overflow-wrap:anywhere;
      min-width:0;
    }
    .offer-body img {
      max-width:100%;
      height:auto;
    }
    .offer-cta { margin-top:16px; }
    .status {
      font-size:14px;
      color:#536759;
    }
    .identity { margin-top:32px; }
    .identity form {
      display:flex;
      gap:12px;
      flex-wrap:wrap;
    }
    .identity input {
      flex:1;
      min-width:180px;
      border:1px solid #b4c1b3;
      border-radius:10px;
      padding:12px;
    }
    .cart-row {
      display:flex;
      gap:16px;
      align-items:center;
      border-bottom:1px solid var(--line);
      padding:18px 0;
    }
    .cart-row .item-info { flex:1; }
    .cart-row button { padding:8px 14px; }
    .summary {
      text-align:right;
      padding-top:20px;
    }
    .consent {
      margin:24px 0;
      background:#e9eddf;
      border-radius:18px;
      padding:22px;
      display:flex;
      align-items:center;
      justify-content:space-between;
      gap:20px;
    }
    .consent p {
      margin:0;
      max-width:640px;
    }
    .secondary {
      background:#e7ecdf;
      color:var(--green);
    }
    footer {
      max-width:1120px;
      margin:auto;
      padding:24px;
      text-align:right;
      font-size:12px;
      letter-spacing:1px;
      border-top:1px solid var(--line);
    }
    details { margin-top:24px; }
    pre {
      white-space:pre-wrap;
      overflow-wrap:anywhere;
      font-size:12px;
    }
    label {
      display:block;
      margin-bottom:10px;
    }
    .actions {
      display:flex;
      gap:12px;
      flex-wrap:wrap;
    }
    
    @media(max-width:640px) {
      nav {
        padding:16px;
        gap:12px;
        flex-wrap:wrap;
      }
      .brand { font-size:22px; }
      .nav-links {
        order:3;
        width:100%;
        font-size:14px;
      }
      .cart-link { margin-left:auto; }
      main { padding:20px 16px 40px; }
      .hero { padding:38px 24px; }
      .grid,.detail { grid-template-columns:1fr; }
      .detail img { height:280px; }
      .detail h1 { font-size:32px; }
      .section-head {
        align-items:flex-start;
        flex-direction:column;
      }
      .consent {
        align-items:flex-start;
        flex-direction:column;
      }
      .identity form { flex-direction:column; }
      .cart-row { flex-wrap:wrap; }
      .panel { padding:20px; }
    }
    #offer-content > section {
      box-sizing: border-box;
      padding: 32px 36px !important;
      border-radius: 20px !important;
    }
    @media (max-width: 640px) {
      #offer-content > section {
        padding: 26px 22px !important;
      }
    }
    </style>
    </head>
    
    <body>
    
    <header>
      <nav>
        <a class="brand" href="?">
          TrailNest<span style="color:#82926c">.</span>
        </a>
    
        <div class="nav-links">
          <a href="?view=category&amp;category=tents">Tents</a>
          <a href="?view=category&amp;category=sleeping">Sleeping</a>
        </div>
    
        <a class="cart-link" href="?view=cart">
          Bag (<span id="cart-count">0</span>)
        </a>
      </nav>
    </header>
    
    <main id="trailnest-app">
    
      <div id="page-content"></div>
    
      <div id="consent-panel" class="consent">
        <p>
          Enable personalized offers for this demo. TrailNest will use a
          browser identifier and your product browsing activity to suggest offers.
        </p>
        <button id="enable-personalization" type="button">
          Enable personalized offers
        </button>
      </div>
    
      <section class="offer-area" aria-labelledby="offer-title">
        <div class="section-head">
          <div>
            <p class="eyebrow">A LITTLE EXTRA FOR YOUR ADVENTURE</p>
            <h2 id="offer-title">An offer for your next escape</h2>
          </div>
    
          <button id="refresh-offer" type="button" disabled>
            Refresh offer
          </button>
        </div>
    
        <p id="offer-status" class="status" role="status">
          Enable personalization to discover your offer.
        </p>
    
        <div id="offer-content" class="offer-body" hidden></div>
      </section>
    
      <section class="panel identity">
        <h2>Make it personal</h2>
    
        <p class="muted">
          For this demo, enter the email address of a test customer already
          in CRM. This is not a login or an email subscription.
        </p>
    
        <form id="email-form">
          <div style="flex:1">
            <label for="demo-email">Test customer email</label>
            <input
              id="demo-email"
              type="email"
              required
              autocomplete="off"
              style="width:100%"
            >
          </div>
    
          <button id="email-submit" disabled type="submit">
            Connect my activity
          </button>
        </form>
    
        <p id="email-status" class="status" role="status"></p>
      </section>
    
      <details id="debug-panel" hidden>
        <summary>Demo diagnostics</summary>
        <p>Browser ID: <code id="browser-id">Not ready</code></p>
        <pre id="debug-output"></pre>
      </details>
    
    </main>
    
    <footer>
      TRAILNEST DEMO · DESIGNED &amp; BUILT BY NOBUYUKI WATANABE
    </footer>
    
    <script>
    /*
     * Replace YOUR_DATA_360_TENANT with the tenant name
     * of your Real-Time Offer Management org.
     */
    window.TrailNestOfferConfig = {
      endpoint:
        'https://YOUR_DATA_360_TENANT.c360a.salesforce.com/personalization/decisions',
    
      dataspace: 'default',
      point: 'TrailNest_Offer_Recommendations',
    
      webChannelId: '0eFWs000000ZQKmMAO',
      treatmentsField: 'ssot__OfferTreatment__dlm',
    
      consentKey: 'trailnest-offers-demo-consent-v1',
      cartKey: 'trailnest-offers-demo-cart-v1',
    
      products: [
        {
          code: 'TN-TE-002',
          id: '01tWs00000HJotlIAD',
          categoryId: '0ZGWs000001fGLBOA2',
          category: 'tents',
          name: 'Trail Duo Tent',
          price: 249,
          image:
            'https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/TN-TE-002.png',
          description:
            'A welcoming home for two, from quiet lakesides to mountain mornings.'
        },
        {
          code: 'TN-SL-001',
          id: '01tWs00000HJovNIAT',
          categoryId: '0ZGWs000001fGMnOAM',
          category: 'sleeping',
          name: 'Alpine Down Sleeping Bag',
          price: 199,
          image:
            'https://image.s12.sfmc-content.com/lib/fe3311727364047e721170/m/1/TN-SL-001.png',
          description:
            'Pack light. Sleep comfortably. Wake ready for the next adventure.'
        }
      ]
    };
    
    (() => {
      'use strict';
    
      const C = window.TrailNestOfferConfig;
      const $ = id => document.getElementById(id);
    
      const params = new URLSearchParams(location.search);
      const root = $('trailnest-app');
      const content = $('page-content');
    
      const getStored = (key, fallback) => {
        try {
          return JSON.parse(sessionStorage.getItem(key)) ?? fallback;
        } catch {
          return fallback;
        }
      };
    
      const setStored = (key, value) => {
        try {
          sessionStorage.setItem(key, JSON.stringify(value));
        } catch {
          // Continue without session persistence.
        }
      };
    
      let cart = getStored(C.cartKey, {});
    
      if (!cart || typeof cart !== 'object' || Array.isArray(cart)) {
        cart = {};
      }
    
      for (const key of Object.keys(cart)) {
        if (
          !C.products.some(product => product.code === key) ||
          !Number.isInteger(cart[key]) ||
          cart[key] < 1 ||
          cart[key] > 99
        ) {
          delete cart[key];
        }
      }
    
      const money = amount =>
        new Intl.NumberFormat('en-US', {
          style: 'currency',
          currency: 'USD'
        }).format(amount);
    
      const escape = value =>
        String(value).replace(/[&<>"']/g, character => ({
          '&': '&amp;',
          '<': '<',
          '>': '>',
          '"': '"',
          "'": '&#39;'
        }[character]));
    
      /*
       * The Sitemap uses this bridge to wait for consent
       * and notify the page when the SDK is ready.
       */
      let resolveConsent;
    
      const consent = new Promise(resolve => {
        resolveConsent = resolve;
      });
    
      let resolveReady;
      let rejectReady;
    
      const ready = new Promise((resolve, reject) => {
        resolveReady = resolve;
        rejectReady = reject;
      });
    
      window.TrailNestOffersBridge = {
        consent,
        ready,
        resolveReady,
        rejectReady
      };
    
      function accept() {
        setStored(C.consentKey, true);
        $('consent-panel').hidden = true;
        resolveConsent();
    
        $('offer-status').textContent =
          'Preparing personalized offers…';
      }
    
      $('enable-personalization').addEventListener('click', accept);
    
      if (getStored(C.consentKey, false) === true) {
        accept();
      }
    
      /*
       * Page rendering.
       */
      const selected = C.products.find(
        product => product.code === params.get('id')
      );
    
      const view = params.get('view') || 'home';
    
      root.dataset.pageType =
        view === 'product' && selected
          ? 'product_detail'
          : view === 'cart'
            ? 'cart'
            : view === 'category'
              ? 'category'
              : 'home';
    
      if (root.dataset.pageType === 'product_detail') {
        Object.assign(root.dataset, {
          productId: selected.id,
          productCode: selected.code,
          categoryId: selected.categoryId,
          productName: selected.name
        });
    
        content.innerHTML = `
          <p>
            <a href="?">Home</a> / ${escape(selected.category)}
          </p>
    
          <section class="detail">
            <img
              src="${selected.image}"
              alt="${escape(selected.name)}"
            >
    
            <div>
              <p class="eyebrow">
                TRAILNEST · ${escape(selected.category)}
              </p>
    
              <h1>${escape(selected.name)}</h1>
              <p>${escape(selected.description)}</p>
              <p class="price">${money(selected.price)}</p>
    
              <div class="actions">
                <button
                  type="button"
                  data-add="${selected.code}"
                >
                  Add to bag
                </button>
    
                <a class="button secondary" href="?view=cart">
                  View bag
                </a>
              </div>
    
              <p id="cart-message" role="status"></p>
            </div>
          </section>
        `;
      } else if (view === 'cart') {
        renderCart();
      } else {
        const list = view === 'category'
          ? C.products.filter(
              product => product.category === params.get('category')
            )
          : C.products;
    
        const hero = view === 'category' ? '' : `
          <section class="hero">
            <p class="eyebrow">
              TRAILNEST · FIND YOUR NEXT CHAPTER
            </p>
    
            <h1>A little closer<br>to the wild.</h1>
    
            <p>
              Thoughtful gear. Fresh air.
              A few good reasons to get outside.
            </p>
    
            <a class="button" href="#shop">
              Explore the collection
            </a>
          </section>
        `;
    
        content.innerHTML = hero + `
          <div class="section-head" id="shop">
            <h2>
              ${
                view === 'category'
                  ? 'Explore the collection'
                  : 'Made for your next escape'
              }
            </h2>
            <span class="muted">
              Two essentials. Endless possibilities.
            </span>
          </div>
    
          <div class="grid">
            ${list.map(product => `
              <article class="card">
                <a href="?view=product&amp;id=${product.code}">
                  <img
                    src="${product.image}"
                    alt="${escape(product.name)}"
                  >
    
                  <div class="card-body">
                    <p class="eyebrow">
                      ${escape(product.category)}
                    </p>
    
                    <h3>${escape(product.name)}</h3>
                    <p>${escape(product.description)}</p>
                    <p class="price">${money(product.price)}</p>
                    <span>Discover this gear →</span>
                  </div>
                </a>
              </article>
            `).join('')}
          </div>
        `;
      }
    
      /*
       * Demo cart.
       */
      function updateCount() {
        $('cart-count').textContent =
          Object.values(cart).reduce((total, quantity) => {
            return total + quantity;
          }, 0);
    
        setStored(C.cartKey, cart);
      }
    
      function renderCart() {
        const rows = C.products.filter(product => cart[product.code]);
    
        const subtotal = rows.reduce((total, product) => {
          return total + product.price * cart[product.code];
        }, 0);
    
        content.innerHTML = `
          <section class="panel">
            <p class="eyebrow">YOUR NEXT ADVENTURE</p>
            <h1>Your bag</h1>
    
            ${
              rows.length
                ? rows.map(product => `
                    <div class="cart-row">
                      <div class="item-info">
                        <a href="?view=product&amp;id=${product.code}">
                          ${escape(product.name)}
                        </a>
                        <div>
                          ${money(product.price)} × ${cart[product.code]}
                        </div>
                      </div>
    
                      <button
                        type="button"
                        class="secondary"
                        data-remove="${product.code}"
                      >
                        Remove
                      </button>
                    </div>
                  `).join('')
                : '<p>Your bag is ready for an adventure.</p>'
            }
    
            <div class="summary">
              <strong>Subtotal ${money(subtotal)}</strong>
              <p class="muted">
                Demo prices. Promotions have not been applied.
                Checkout is not available in this demo.
              </p>
            </div>
    
            <a href="?">Continue exploring →</a>
          </section>
        `;
      }
    
      updateCount();
    
      content.addEventListener('click', event => {
        const add = event.target.closest('[data-add]');
        const remove = event.target.closest('[data-remove]');
    
        if (add) {
          const code = add.dataset.add;
    
          cart[code] = Math.min(99, (cart[code] || 0) + 1);
    
          updateCount();
    
          $('cart-message').textContent = 'Added to your bag.';
        }
    
        if (remove) {
          delete cart[remove.dataset.remove];
          updateCount();
          renderCart();
        }
      });
    
      $('debug-panel').hidden = params.get('debug') !== '1';
    
      /*
       * Render only supported presentation tags and CSS.
       * Scripts and event handler attributes are not copied.
       */
      function safeMarkup(html) {
        const template = document.createElement('template');
        const fragment = document.createDocumentFragment();
    
        template.innerHTML = html;
    
        const allowedTags = new Set([
          'SECTION',
          'DIV',
          'P',
          'H1',
          'H2',
          'H3',
          'H4',
          'SPAN',
          'STRONG',
          'EM',
          'B',
          'I',
          'BR',
          'UL',
          'OL',
          'LI'
        ]);
    
        const allowedStyles = new Set([
          'box-sizing',
          'width',
          'max-width',
          'padding',
          'padding-top',
          'padding-bottom',
          'margin',
          'margin-top',
          'margin-bottom',
          'border',
          'border-radius',
          'background',
          'background-color',
          'color',
          'font-family',
          'font-size',
          'font-weight',
          'line-height',
          'letter-spacing',
          'text-align'
        ]);
    
        function copy(node, parent) {
          if (node.nodeType === 3) {
            parent.append(document.createTextNode(node.textContent));
            return;
          }
    
          if (
            node.nodeType !== 1 ||
            !allowedTags.has(node.tagName)
          ) {
            return;
          }
    
          const element = document.createElement(
            node.tagName.toLowerCase()
          );
    
          for (let index = 0; index < node.style.length; index++) {
            const property = node.style.item(index);
            const value = node.style.getPropertyValue(property);
    
            if (
              allowedStyles.has(property) &&
              !/(url\s*\(|expression\s*\(|@import)/i.test(value)
            ) {
              element.style.setProperty(property, value);
            }
          }
    
          for (const child of node.childNodes) {
            copy(child, element);
          }
    
          parent.append(element);
        }
    
        for (const node of template.content.childNodes) {
          copy(node, fragment);
        }
    
        return fragment;
      }
    
      function safeUrl(value) {
        try {
          const url = new URL(value, location.href);
    
          return ['https:', 'http:'].includes(url.protocol)
            ? url.href
            : null;
        } catch {
          return null;
        }
      }
    
      let sdk;
      let busy = false;
    
      /*
       * Request and render the selected offer.
       */
      async function refreshOffer() {
        if (!sdk || busy) {
          return;
        }
    
        busy = true;
    
        $('refresh-offer').disabled = true;
        $('offer-content').hidden = true;
        $('debug-output').textContent = '';
    
        $('offer-status').textContent =
          'Finding an offer for you…';
    
        const controller = new AbortController();
    
        const timer = setTimeout(() => {
          controller.abort();
        }, 12000);
    
        try {
          if (C.endpoint.includes('YOUR_DATA_360_TENANT')) {
            throw new Error(
              'Replace YOUR_DATA_360_TENANT in the CloudPages HTML.'
            );
          }
    
          const individualId = sdk.getAnonymousId();
    
          if (!individualId) {
            throw new Error('Browser ID is not available.');
          }
    
          $('browser-id').textContent = individualId;
    
          const response = await fetch(C.endpoint, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            signal: controller.signal,
    
            body: JSON.stringify({
              context: {
                individualId,
                dataspace: C.dataspace,
                channel: 'Web',
                requestUrl: location.href
              },
              personalizationPoints: [
                {
                  name: C.point
                }
              ]
            })
          });
    
          if (!response.ok) {
            throw new Error(
              `Decision request failed: HTTP ${response.status}`
            );
          }
    
          const payload = await response.json();
    
          $('debug-output').textContent =
            JSON.stringify(payload, null, 2);
    
          const result = payload.personalizations?.find(
            item => item.personalizationPointName === C.point
          );
    
          const offer = result?.data?.[0];
    
          if (!offer) {
            $('offer-status').textContent =
              'No offer was returned. Please try again later.';
            return;
          }
    
          /*
           * Support both response shapes:
           *
           * ssot__OfferTreatment__dlm: { ... }
           * ssot__OfferTreatment__dlm: [ { ... }, ... ]
           */
          const rawTreatments = offer[C.treatmentsField];
    
          const treatments = Array.isArray(rawTreatments)
            ? rawTreatments
            : rawTreatments && typeof rawTreatments === 'object'
              ? [rawTreatments]
              : [];
    
          if (treatments.length === 0) {
            throw new Error(
              'Offer Treatment was not returned. Check the Content Schema.'
            );
          }
    
          const treatment = treatments.find(item => {
            return item &&
              item.ssot__EngagementChannelType__c === C.webChannelId;
          });
    
          if (!treatment) {
            throw new Error(
              'No Web treatment was returned. Check the channel ID and Content Schema.'
            );
          }
    
          const host = $('offer-content');
          host.replaceChildren();
    
          if (
            treatment.ssot__OutputFormatType__c === 'HTML' &&
            treatment.ssot__OutputText__c
          ) {
            host.append(
              safeMarkup(treatment.ssot__OutputText__c)
            );
          } else {
            const panel = document.createElement('div');
            panel.className = 'panel';
    
            const heading = document.createElement('h2');
            heading.textContent =
              treatment.ssot__OfferHeadingText__c ||
              offer.ssot__Name__c ||
              '';
    
            const body = document.createElement('p');
            body.textContent =
              treatment.ssot__OfferBodyText__c || '';
    
            panel.append(heading, body);
            host.append(panel);
          }
    
          const url = treatment.ssot__CallToActionUrl__c
            ? safeUrl(treatment.ssot__CallToActionUrl__c)
            : null;
    
          if (url && treatment.ssot__CallToActionText__c) {
            const link = document.createElement('a');
    
            link.className = 'button offer-cta';
            link.href = url;
            link.textContent = treatment.ssot__CallToActionText__c;
    
            host.append(link);
          }
    
          host.dataset.offerId = offer.ssot__Id__c || '';
          host.dataset.offerCode = offer.ssot__OfferCodeText__c || '';
          host.dataset.personalizationId =
            result.personalizationId || '';
    
          host.hidden = false;
    
          $('offer-status').textContent =
            'Offer terms apply. Eligibility is checked when a promotion is applied.';
    
        } catch (error) {
          console.error('[TrailNest offers]', error);
    
          $('offer-status').textContent =
            'We could not load your offer. Please try again.';
    
          $('debug-output').textContent += '\n' + error.message;
    
        } finally {
          clearTimeout(timer);
          busy = false;
          $('refresh-offer').disabled = false;
        }
      }
    
      $('refresh-offer').addEventListener('click', refreshOffer);
    
      /*
       * Submit the test customer's email address.
       */
      $('email-form').addEventListener('submit', async event => {
        event.preventDefault();
    
        if (!sdk) {
          return;
        }
    
        const email = $('demo-email').value.trim();
    
        if (!$('demo-email').reportValidity()) {
          return;
        }
    
        $('email-submit').disabled = true;
    
        try {
          await sdk.sendEvent({
            interaction: {
              name: 'Customer Confirm Email'
            },
            user: {
              attributes: {
                eventType: 'contactPointEmail',
                email
              }
            }
          });
    
          $('email-status').textContent =
            'Your email event has been submitted. Profile matching may take time; refresh the offer after confirming the data.';
    
          $('demo-email').value = '';
    
        } catch (error) {
          console.error('[TrailNest email]', error);
    
          $('email-status').textContent =
            'The email event could not be submitted. Please try again.';
    
        } finally {
          $('email-submit').disabled = false;
        }
      });
    
      /*
       * The existing Sitemap resolves this promise
       * after initializing the SDK.
       */
      ready.then(instance => {
        sdk = instance;
    
        $('email-submit').disabled = false;
        $('refresh-offer').disabled = false;
    
        void refreshOffer();
    
      }).catch(error => {
        console.error('[TrailNest SDK]', error);
    
        $('offer-status').textContent =
          'Personalization could not start. Check the SDK and sitemap setup.';
      });
    
    })();
    </script>
    
    <!-- PASTE THE WEB SDK SCRIPT TAG FROM "TrailNest Offers Website" HERE.
         Register TrailNest_Offers_Sitemap.js in that same Website Connector.
         Do not paste a second SDK initialization script into this HTML. -->
    
    </body>
    </html>
    画像

    テナントエンドポイントを変更する

    HTML 内で、次の設定を検索します。

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

    YOUR_DATA_360_TENANT を、今回の Real-Time Offer Management 組織の Data 360 テナント名に置き換えます。テナント名は Data Cloud Setup 画面にあります。

    画像

    その下の設定は、前回の記事で作成した内容に合わせてあります。

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

    商品とカテゴリの ID

    今回の HTML には、これまで確認した次の ID を設定しています。

    • Trail Duo Tent:01tWs00000HJotlIAD

    • Alpine Down Sleeping Bag:01tWs00000HJovNIAT

    • Tents:0ZGWs000001fGLBOA2

    • Sleeping:0ZGWs000001fGMnOAM

    商品ページの URL には TN-TE-002 などの商品コードを使用しますが、Data 360 に送信する商品・カテゴリには、照合用の ID を使用します。

    その対応関係を、今回のサンプルでは HTML 内に設定しています。


    ② Web SDK の読み込みタグを追加する

    Data 360 で、前の記事で作成した Website Connector を開きます。

    • Name:TrailNest Offers Website

    この Website Connector の Web SDK を読み込む script タグを取得します。

    画像

    CloudPages の HTML の末尾に、次のコメントがあります。

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

    この位置へ、取得した script タグを下記のように加工して貼り付けます。

    <script src="https://cdn.c360a.salesforce.com/beacon/c360a/5404ac93-0fce-4bc1-9ac5-c39771369434/scripts/c360a.min.js"></script>
    画像

    ③ Sitemap を登録する

    続いて、以下の全体を、Website Connector の Sitemap に登録します。

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

    今回のファイルは、この検証用サイトに合わせた設定です。

    画像

    主な処理は、次のとおりです。

    1. Web サイトで、パーソナライズの利用に同意されるのを待つ。

    2. Salesforce Interactions SDK を初期化する。

    3. ブラウザの Profile 情報を送信する。

    4. 現在のページを判定する。

    5. 商品詳細ページの場合は、商品閲覧イベントを送信する。

    6. CloudPages 側でオファーを取得できる状態にする。

    SDK の基本的な順序は、init() による初期化後に、initSitemap() で Sitemap を設定する形です。

    商品閲覧で送信する情報

    商品詳細ページでは、次の情報を送信します。

    • Interaction:View Catalog Object

    • Catalog Object Type:Product

    • Catalog Object ID:Salesforce の商品 ID

    • categoryId:Salesforce のカテゴリ ID

    コード上では、次の部分に該当します。

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

    SDK の catalogObject.attributes.categoryId は、Web Connector 側では attributeCategoryId として扱われます。


    ④ 公開ページで SDK とオファー取得を確認する

    CloudPages のトップの公開 URL を開きます。

    最初は、次のボタンが表示されるのでクリックします。

    • Enable personalized offers

    画像

    クリックすると、今回の検証用トラッキングに同意し、SDK の初期化とイベント送信を開始します。

    今回のサンプルでは、この同意をタブ単位の sessionStorage に記録します。同じタブ内で商品ページへ移動しても、毎回ボタンを押す必要はありません。SDK は、トラッキングへの同意を受け取ってからデータ収集を開始する仕組みです。

    商品をまだ閲覧していない状態で、共通オファーの見出し 「Your Next Adventure Starts Here」 が表示されることを確認します。

    画像

    認証の設定について
    今回は、Personalization Point の Authentication Required を未チェックにした構成です。CloudPages に Salesforce のログイン情報やアクセストークンを設定する必要はありません。


    ⑤ テスト顧客のメールアドレスを入力する

    ページ下部の Make it personal に、CRM に登録済みのテスト顧客のメールアドレスを入力します。

    画像

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

    画像

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

    画像

    ⑥ 商品を閲覧してオファーの変化を確認する

    1. Tents のオファーを確認する

    1. Trail Duo Tent をクリックし、商品詳細ページを開きます。

    画像

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

    画像

    リアルタイムとは言え、イベントの取り込み・反映には時間がかかるため、「閲覧した瞬間に必ず切り替わる」わけではありません。

    3. テントの 10% 割引を案内するオファーに変わることを確認します。

    画像

    2. Sleeping のオファーを確認する

    Sleeping だけを確認する場合は、別のブラウザプロファイルと、別のテスト顧客を使用します。

    1. トップページを開き、Enable personalized offers をクリックします。

    2. 別のテスト顧客のメールアドレスを入力し、Connect my activity をクリックします。

    3. Alpine Down Sleeping Bag をクリックします。

    4. 必要に応じて Refresh offer をクリックし、Sleeping の 15% 割引を案内するオファーに変わることを確認します。

    画像

    同じ顧客が Tents と Sleeping の両方を閲覧した場合は、今回の優先順位では Tents の方が優先順位が高いので Tents のオファーが選ばれます。


    ⑦ そもそも今回やろうとしていることとは

    これは振り返りになりますが、今回は「閲覧したカテゴリに関連するオファー(特典)を案内する」仕組みです。閲覧回数が多いカテゴリを選ぶ設定ではありません。

    今回の設定では、次のように選びます。

    • Tents を閲覧した人:テントの 10% OFF オファー

    • Sleeping を閲覧した人:寝袋などの 15% OFF オファー

    • 対象カテゴリの閲覧がない人:150ドル以上の購入で15ドル OFF の共通オファー(Weekend)

    両方を閲覧した場合は、設定した優先順位で選びます。

    • Tents:優先順位 10

    • Sleeping:優先順位 20

    今回は数字が小さい方を優先するため、Tents のオファーが表示されます。 Sleeping を何度も見たり、最後に見たりしても、その理由で Sleeping に切り替わる構成ではありません。

    分かりやすく言えば、テントを一度でも見たら、テントのクーポンが出続けます。ただし「一度見たら永久に」ではなく、今回設定した過去 7 日間の閲覧データに Tents が残っている間だけです。

    クーポンに優先順位をつけ、他のカテゴリより、より興味のあるカテゴリのある人に、関連するクーポンを優先的に出すという内容になっています。

    また、現段階では「特典の案内」を表示しているだけです。クーポンコードの発行や、カート金額への割引適用はまだ行っていません。そこは次の実装で対応する予定です。


    いかがでしたでしょうか。

    今回は、CloudPages から商品閲覧を送信し、Personalization が選んだオファーを Web サイトへ表示する構成を用意しました。

    顧客の閲覧情報と、Offer に関連付けたカテゴリを比較することで、その顧客に案内する特典を切り替えます。

    また、メールアドレスを送信することで、前の記事で準備した CRM 顧客との照合にもつながります。

    次回は、案内した特典を実際のカートへ適用する処理に進みます。

    今回は以上です。


    次の記事はこちら

    前回の記事はこちら

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

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