> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aftersell.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Upcartアップセル

> Strategyを使って、UpcartのUpsellsモジュールに表示する商品を動的に選択します。

<div id="overview">
  ## 概要
</div>

UpcartはAftersellとは別のアプリであるため、Aftersellのポストパーチェスやチェックアウトのフローとは異なり、StrategyはUpsellsモジュールに組み込まれていません。代わりに、Strategies APIを直接呼び出してその結果をUpcartの公開APIを通じて既存のUpsellsモジュールに渡す、小さなスクリプトで2つのアプリを橋渡しします。

このスクリプトは**そのまま使える**ものです。UpcartのカスタムHTMLに一度貼り付け、2つの値(Strategy APIキーとStrategy ID)を置き換えるだけで、UpsellsモジュールにはStrategyが返す商品が表示されるようになります。

***

<div id="what-youll-need">
  ## 必要なもの
</div>

1. **Strategy APIキー。** Aftersellで **Settings → Product Strategy** に移動し、**Security Token** カードでトークンをコピーします(これがStrategy APIキーです)。
2. **Strategy ID。** AftersellのStrategyエディターで実行したいStrategyを開き、そのIDをコピーします。
3. **UpcartでUpsellsモジュールが有効になっていること。** スクリプトは既存のアップセルブロックに表示される商品リストを上書きするため、何かをレンダリングするにはモジュールがオンになっている必要があります。

***

<div id="adding-the-script">
  ## スクリプトの追加
</div>

Upcartで **Settings → Custom HTML → Scripts (before load)** に移動し、以下のスクリプトを貼り付けます。`STRATEGY_ID` と `STRATEGY_API_KEY` をAftersellで取得した値に置き換えてから保存します。

```html theme={"theme":{"light":"snazzy-light","dark":"github-dark"}}
<script>
  const STRATEGY_ID = "YOUR_STRATEGY_ID";
  const STRATEGY_API_KEY = "YOUR_STRATEGY_API_KEY";
  const STRATEGY_BACKEND_URL = "https://start.aftersell.app";

  let cartToken = null;
  const fetchCartToken = async () => {
    const res = await fetch("/cart.js");
    const c = await res.json();
    cartToken = c.token;
  };

  const mapCartItemToContext = (cartItem) => ({
    productId: "gid://shopify/Product/" + cartItem.productId.toString(),
    variantId: "gid://shopify/ProductVariant/" + cartItem.variantId.toString(),
    tags: [],
    title: cartItem.title,
    vendor: cartItem.vendor,
    productType: cartItem.productType,
    handle: cartItem.handle,
    quantity: cartItem.quantity,
    price: cartItem.originalPrice / 100,
  });

  // --- StrategyProduct -> Upcart Product conversion -----------------------

  const gidToNumericId = (gid) => Number(String(gid).split("/").pop());
  const priceStringToCents = (price) =>
    price == null ? null : Math.round(parseFloat(price) * 100);

  const strategyMetafieldsToProductMetafields = (metafields = []) => {
    const grouped = {};
    for (const { namespace, key, value } of metafields) {
      grouped[namespace] = grouped[namespace] || {};
      grouped[namespace][key] = value;
    }
    return { product: grouped };
  };

  const deriveProductOptions = (variants = []) => {
    const byName = new Map();
    for (const variant of variants) {
      (variant.selectedOptions ?? []).forEach((opt, idx) => {
        if (!byName.has(opt.name)) {
          byName.set(opt.name, { name: opt.name, position: idx + 1, values: [] });
        }
        const entry = byName.get(opt.name);
        if (!entry.values.includes(opt.value)) entry.values.push(opt.value);
      });
    }
    return [...byName.values()];
  };

  const mapStrategyVariantToProductVariant = (variant) => {
    const selected = variant.selectedOptions ?? [];
    const optionValues = selected.map((o) => o.value);
    return {
      id: gidToNumericId(variant.variantId),
      title: variant.title,
      option1: optionValues[0] ?? null,
      option2: optionValues[1] ?? null,
      option3: optionValues[2] ?? null,
      sku: variant.sku ?? "",
      requires_shipping: true,
      taxable: true,
      featured_image: null,
      available: variant.availableForSale,
      name: variant.title,
      public_title: variant.title,
      options: optionValues,
      price: priceStringToCents(variant.price) ?? 0,
      weight: 0,
      compare_at_price: priceStringToCents(variant.compareAtPrice),
      inventory_management: "",
      barcode: null,
      requires_selling_plan: false,
      selling_plan_allocations: [],
    };
  };

  const mapStrategyProductToProduct = (product) => {
    const variants = (product.variants ?? []).map(mapStrategyVariantToProductVariant);
    const variantPrices = variants.map((v) => v.price);
    const priceMin = variantPrices.length ? Math.min(...variantPrices) : (priceStringToCents(product.price) ?? 0);
    const priceMax = variantPrices.length ? Math.max(...variantPrices) : (priceStringToCents(product.price) ?? 0);

    const variantCompareAtPrices = variants
      .map((v) => v.compare_at_price)
      .filter((p) => p != null);
    const compareAtMin = variantCompareAtPrices.length ? Math.min(...variantCompareAtPrices) : 0;
    const compareAtMax = variantCompareAtPrices.length ? Math.max(...variantCompareAtPrices) : 0;

    const images = (product.images ?? [])
      .slice()
      .sort((a, b) => a.position - b.position)
      .map((img) => img.src);

    return {
      id: gidToNumericId(product.productId),
      title: product.title,
      handle: product.handle,
      description: product.description ?? "",
      published_at: "",
      created_at: "",
      vendor: product.vendor ?? "",
      type: product.productType ?? "",
      tags: [...(product.tags ?? [])],
      price: priceStringToCents(product.price) ?? 0,
      price_min: priceMin,
      price_max: priceMax,
      available: product.availableForSale,
      price_varies: priceMin !== priceMax,
      compare_at_price: priceStringToCents(product.compareAtPrice),
      compare_at_price_min: compareAtMin,
      compare_at_price_max: compareAtMax,
      compare_at_price_varies: compareAtMin !== compareAtMax,
      variants,
      images,
      featured_image: images[0] ?? "",
      options: deriveProductOptions(product.variants),
      url: product.url ?? "",
      media: [],
      requires_selling_plan: false,
      selling_plan_groups: [],
      metafields: strategyMetafieldsToProductMetafields(product.metafields),
    };
  };

  // -----------------------------------------------------------------------

  let replacedUpsells = null;
  let lastFetchedCartSignature = null;

  const cartSignature = (cart) =>
    JSON.stringify(cart.items.map((i) => [i.variantId, i.quantity]));

  const runStrategyEvaluation = async () => {
    if (!STRATEGY_ID || !STRATEGY_API_KEY) return;

    await fetchCartToken();
    if (!cartToken) return;

    const cart = window.upcartGetCart();
    if (!cart) return;

    const signature = cartSignature(cart);
    if (signature === lastFetchedCartSignature) return;
    lastFetchedCartSignature = signature;

    const cartContext = {
      subtotal: cart.total_price / 100,
      itemCount: cart.items.reduce((acc, item) => acc + item.quantity, 0),
      lineCount: cart.items.length,
    };

    const products = cart.items.map(mapCartItemToContext);

    const res = await fetch(STRATEGY_BACKEND_URL + "/api/public/strategy/evaluate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Strategy-Api-Key": STRATEGY_API_KEY,
      },
      body: JSON.stringify({
        shopDomain: window.Shopify.shop,
        strategyId: STRATEGY_ID,
        context: {
          products,
          cartToken,
          cart: cartContext.itemCount > 0 ? cartContext : undefined,
          session: { currencyCode: window.Shopify.currency.active },
        },
      }),
    });
    replacedUpsells = await res.json();

    if (typeof window.upcartRefreshCart === "function") {
      window.upcartRefreshCart();
    }
  };

  window.upcartSubscribeCartUpdated(runStrategyEvaluation);

  const waitForUpcartCart = (timeoutMs = 10000) =>
    new Promise((resolve) => {
      const start = Date.now();
      const check = () => {
        if (window.upcartGetCart()) return resolve(true);
        if (Date.now() - start > timeoutMs) return resolve(false);
        setTimeout(check, 100);
      };
      check();
    });

  waitForUpcartCart().then((ready) => {
    if (ready) runStrategyEvaluation();
  });

  window.upcartModifyListOfUpsells = () => {
    if (!replacedUpsells || !Array.isArray(replacedUpsells.products)) return;
    try {
      return replacedUpsells.products.map(mapStrategyProductToProduct);
    } catch (err) {
      console.error("upcartModifyListOfUpsells mapping failed", err);
      return;
    }
  };
</script>
```

<Warning>
  Strategy APIキーは、あなたのショップのStrategyに対する呼び出しを認可します。上記のスクリプトはこのキーをクライアントサイドのコードに配置しますが、これはcart drawerからAPIを呼び出す唯一の現実的な方法です。このキーは他の公開ストアフロント認証情報と同様に扱い、意図しない形で漏洩した場合は、Aftersellの **Settings → Product Strategy** からローテーションしてください。
</Warning>

***

<div id="what-the-strategy-sees">
  ## Strategyが参照できる情報
</div>

これはストアフロントのカートから実行されるため、コンテキストはAftersellのネイティブなサーフェスで利用できるものの限定的なサブセットになります:

<div id="product-context">
  #### 商品コンテキスト
</div>

現在Upcartのカートに入っているラインアイテムが入力商品として送信されます。**商品タイプ**、**ベンダー**、**商品ハンドル**、**商品タイトル**、および **商品ID / バリアントID** などのトリガーは、すべてこれらのアイテムに対して評価されます。

<div id="cart-context">
  #### カートコンテキスト
</div>

* **Subtotal** - ストアの主要通貨単位(例: ドル)でのカート小計。Upcartの `total_price` は補助単位(セント)なので、スクリプトはStrategies APIの他の部分が使用する単位、つまり `cart_subtotal` ルールを記述する単位に合わせるために100で割ります。
* **Item count** - 全ライン合計の数量。
* **Line count** - 個別のラインアイテムの数。

<div id="session-context">
  #### セッションコンテキスト
</div>

* **通貨コード** - `window.Shopify.currency.active` から取得されます。

<Warning>
  **顧客トリガーとUTMトリガーはマッチしません。** デフォルトのスクリプトは、顧客タグ、注文数、所在地、UTMパラメーターを送信しないため、これらのトリガーを使用するルールは決して発火しません。常に何かが返されるように、商品、カート、通貨のトリガー、またはCatch allを使用してください。
</Warning>

***

<div id="what-happens-when-the-strategy-returns">
  ## Strategyが結果を返すとどうなるか
</div>

Strategyが返した商品は、UpcartがUpsellsモジュールに本来表示するリストを完全に**置き換え**ます。マーチャントが定義したアップセルリストは、そのカートの間は上書きされます — マージされるわけではありません。

各Strategy商品は、Upcartが期待する商品の形式(バリアント、画像、オプション、メタフィールドなど)にマッピングされるため、他の商品とまったく同じようにアップセルブロック内にレンダリングされます。

***

<div id="when-no-product-is-returned">
  ## 商品が返されない場合
</div>

Strategyが商品を返さない場合、Upsellsモジュールは**空**でレンダリングされ、アップセルは表示されません。

これを避けるには、Strategyに **Catch all** を設定して、常にフォールバック商品が返されるようにします。Catch allの設定方法については、[Strategyの構築](/ja/aftersell/strategies_building_in_app)ページを参照してください。

***

<div id="re-evaluation-on-cart-changes">
  ## カート変更時の再評価
</div>

チェックアウトアップセルとは異なり、Upcartの実装は**カートが変更されるたびにStrategyを再評価**します — アイテムの追加、削除、数量の変更などです。スクリプトはUpcartの `cartUpdated` イベントをサブスクライブし、新しいカートをStrategies APIに送信して、新しいアップセルリストでcart drawerを更新します。

カートシグネチャのチェックにより、ラインアイテムと数量が実際に変わっていない場合は冗長な呼び出しがスキップされるため、カートを実質的に変更しない連続したカートイベントがAPIを再度呼び出すことはありません。

***

<div id="tips-for-upcart-strategies">
  ## Upcart Strategyのヒント
</div>

* **カートを軸に設計する。** ここで最も強力なシグナルは、カートの構成と商品のトリガーです。顧客履歴やUTMベースのターゲティングは、デフォルトのスクリプトでは送信されません。
* **Catch allをセーフティネットとして使う。** Catch allがないと、ルールが1つもマッチしないときにUpsellsモジュールには何も表示されません。
* **デフォルトでキャッシュフレンドリー。** カートシグネチャのガードにより、カートが実質的に変わっていない場合はAPIが再度呼び出されません。カートを編集せずに開閉を繰り返す買い物客に有効です。
