> ## 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의 업셀 모듈에 표시되는 상품을 동적으로 선택하세요.

<div id="overview">
  ## 개요
</div>

Upcart는 Aftersell과 별개의 앱이기 때문에, Aftersell의 구매 후 및 체크아웃 플로우처럼 Strategy가 업셀 모듈에 기본 내장되어 있지 않아요. 대신 Strategies API를 직접 호출하고 그 결과를 Upcart의 공개 API를 통해 기존 업셀 모듈에 전달하는 작은 스크립트로 두 앱을 연결해요.

이 스크립트는 **바로 사용 가능**해요 - Upcart의 커스텀 HTML에 한 번 붙여넣고 두 가지 값(Strategy API 키와 Strategy ID)만 교체하면, 업셀 모듈이 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에서 업셀 모듈 활성화.** 이 스크립트는 기존 업셀 블록에 표시되는 상품 목록을 재정의하므로, 무언가 렌더링되려면 모듈이 켜져 있어야 해요.

***

<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에 대한 호출을 인가해요. 위 스크립트는 키를 클라이언트 측 코드에 배치하는데, 이는 카트 드로어에서 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>

* **소계** - 스토어의 주요 통화 단위(예: 달러) 기준 카트 소계. Upcart의 `total_price`는 보조 단위(센트)이므로, 스크립트가 100으로 나눠서 Strategies API의 나머지 부분이 사용하는 단위 - 그리고 `cart_subtotal` 규칙을 작성한 단위 - 와 맞춰요.
* **아이템 수** - 모든 라인의 총 수량.
* **라인 수** - 서로 다른 라인 아이템의 개수.

<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가 원래 표시할 목록을 완전히 **대체**해요. 판매자가 정의한 업셀 목록은 해당 카트가 유지되는 동안 재정의돼요 - 병합되는 것이 아니에요.

각 Strategy 상품은 Upcart가 기대하는 상품 형태(변형, 이미지, 옵션, 메타필드 등)로 매핑되므로, 다른 상품과 똑같이 업셀 블록 안에 렌더링돼요.

***

<div id="when-no-product-is-returned">
  ## 상품이 반환되지 않을 때
</div>

Strategy가 상품을 반환하지 않으면 업셀 모듈은 **비어 있는 채로** 렌더링돼요 - 업셀이 표시되지 않아요.

이를 피하려면 Strategy에 **Catch all**을 구성하여 항상 반환할 폴백 상품이 있도록 하세요. Catch all 설정 방법은 [Strategy 만들기](/ko/aftersell/strategies_building_in_app) 페이지를 참고하세요.

***

<div id="re-evaluation-on-cart-changes">
  ## 카트 변경 시 재평가
</div>

체크아웃 업셀과 달리 Upcart 구현은 카트가 변경될 때마다 - 상품 추가, 제거 또는 수량 변경 - **Strategy를 다시 평가해요**. 스크립트는 Upcart의 `cartUpdated` 이벤트를 구독하고, 새 카트를 Strategies API로 전송한 다음, 새 업셀 목록으로 카트 드로어를 새로고침해요.

카트 시그니처 검사는 라인 아이템과 수량이 실제로 변경되지 않은 경우 중복 호출을 건너뛰므로, 카트를 실질적으로 변경하지 않는 연속된 카트 이벤트가 API를 다시 호출하지 않아요.

***

<div id="tips-for-upcart-strategies">
  ## Upcart Strategy를 위한 팁
</div>

* **카트를 중심으로 설계하세요.** 카트 형태와 상품 트리거가 여기서 가장 강력한 신호예요. 고객 히스토리와 UTM 기반 타기팅은 기본 스크립트에서 전송되지 않아요.
* **Catch all을 안전망으로 사용하세요.** Catch all이 없으면 어떤 규칙도 매칭되지 않을 때 업셀 모듈에 아무것도 표시되지 않아요.
* **기본적으로 캐시 친화적이에요.** 카트 시그니처 가드는 카트가 실질적으로 변경되지 않았을 때 API 재호출을 방지해요 - 카트를 편집하지 않고 열고 닫기만 반복하는 쇼핑객에게 좋아요.
