SHARE
  1. Top
  2. ブログ一覧
  3. Shopify Checkout UI extensions を使ってチェックアウト画面に商品追加ウィジェットを作る方法
Eコマース

Shopify Checkout UI extensions を使ってチェックアウト画面に商品追加ウィジェットを作る方法

公開日

2025.02.27

更新日

2025.02.27

SHARE
Shopify Checkout UI extensions を使ってチェックアウト画面に商品追加ウィジェットを作る方法のサムネイル

コンビニにおける「レジ横のガム」のように、購入時にさらにもう1つ商品を追加してもらう仕組みってShopifyでもあればいいなと思いませんか?
実はチェックアウト画面に商品アイテム追加ウィジェットを作ることができるんです。今回はその方法をコードを交えて解説します。

Checkout UI extensions とは

Shopify の Checkout UI extensions とは、チェックアウト画面に対して自作のUIコンポーネントを差し込む仕組みのことです。具体的には、Shopifyが提供する拡張API経由でチェックアウト画面を拡張し、購入フロー内に追加のUI要素を表示できるようになります。
たとえば注意書きのバナーや同意事項のチェックボックス、商品の追加ウィジェットなど、様々なカスタマイズが可能です。

これは標準のテーマエディターやLiquidのカスタマイズとは別のレイヤーで動作し、Shopifyが提供する Checkout UI extensions API (2025-01) を介してUI要素をやり取りします。単にデザイン面を変えるだけでなく、追加で商品を勧めたり情報を入力させたりといった、ビジネス視点での効果を狙ったカスタマイズにも活用できます。

使用するAPI

Storefront API - productRecommendations

今回は Storefront API の productRecommendations を使用します。このクエリは、指定した商品に関連する商品を取得するために使用されます。
おすすめ商品の設定方法についてはShopifyテーマカスタマイズで便利な Ajax APIの紹介 - プロダクトレコメンデーション編を参照してください。

Extensions API - query API

Checkout UI extensions では、Storefront API のクエリを使ってデータを取得することができます。

Extensions API - useApplyCartLinesChange

カートアイテムの操作ができます。具体的には以下の3つの操作が可能です。

  • CartLineAddChange: カートに商品を追加します。
  • CartLineRemoveChange: カートから商品を削除します。
  • CartLineUpdateChange: カート内の商品の数量や属性などを変更します。

Extensions API - useCartLines

カート内の商品情報を取得できます。数量や価格、商品idなどが取得可能です。

具体的な実装方法

今回は「カート内1番目にあるアイテムのおすすめ商品を2件表示して追加できるようにする」を目標に進めていきます。

Checkout UI extensions の作成

まずは、以下のコマンドを実行して、チェックアウト画面のextensionを作成します。
(アプリの初期化については割愛します。詳しくはScaffolding an extensionを参照してください。)

shopify app generate extension --template checkout_ui  --flavor typescript-react

アプリ名を求められるので、add-to-cartと入力ましょう。

基本コンポーネントの作成

作成後、extensions/add-to-cart/src/Checkout.tsxを編集していきます。以下のコードに書き換えてください。

import {
  reactExtension,
  BlockStack,
  Text,
  InlineLayout,
  Button,
  View,
  Heading,
} from "@shopify/ui-extensions-react/checkout";

export default reactExtension("purchase.checkout.block.render", () => (
  <Extension />
));

function Extension() {
  return (
    <View>
      <Heading level={3}>こちらの商品もご一緒にいかがでしょうか?</Heading>

      <BlockStack border={"dotted"} padding={"tight"}>
        <InlineLayout>
          <Text>おすすめ商品</Text>
          <Button appearance="monochrome">追加する</Button>
        </InlineLayout>
      </BlockStack>
    </View>
  );
}

変更したら保存して開発サーバーを起動します。

shopify run dev

コンソールにPreview URLが表示されるので、ブラウザでアクセスして動作を確認します。
Developer Consoleが表示されるので作成したadd-to-cartのPreview linkをクリックします。

するとチェックアウト画面でおすすめ商品を追加するウィジェットが表示されるはずです。まだアクションを追加していないので、ボタンをクリックしても何も起こりません。

alt text

追加アクションの実装

次に、おすすめ商品をカートに追加するアクションを実装します。useApplyCartLinesChangeを使ってカートに商品を追加する処理を追加します。
Checkout.tsxに以下のコードを追加してください。merchandiseIdは実際の種類IDに置き換えてください。

function Extension() {
  const cartLineChange = useApplyCartLinesChange();

  const addToCart = async () => {
    try {
      const newCartLine: CartLineChange = {
        type: "addCartLine",
        merchandiseId: "gid://shopify/ProductVariant/123456789",
        quantity: 1,
      };
      await cartLineChange(newCartLine);
    } catch (error) {
      console.warn("Failed to add item to cart:", error);
    }
  };

  return (
    ・・・
    ・・・
    - <Button appearance="monochrome">追加する</Button>
    + <Button appearance="monochrome" onPress={addToCart}>
    +   追加する
    + </Button>

変更後、追加ボタンをクリックするとおすすめ商品がカートに追加されるはずです。

おすすめ商品をStorefront APIで取得

追加商品が固定になっているので、おすすめ商品をStorefront APIで動的に取得するように変更します。

shopify.extension.tomlでapi_accessスコープが必要なので以下のように追加します。

[extensions.capabilities]
api_access = true

続いてproductRecommendationsクエリを記述します。種類数は商品によって変わるのでここではGetProductRecommendationsクエリで商品種類数を取得し、GetProductVariantsクエリを用意して種類を取得するという2段構えにします。
Checkout.tsxに以下のコードを追加してください。後ほど金額や画像を表示するためにフィールドを追加しています。

// https://shopify.dev/docs/api/storefront/latest/queries/productRecommendations
const GET_PRODUCT_RECOMMENDATIONS = `#graphql
  query GetProductRecommendations($id: ID!) {
    productRecommendations(productId: $id) {
      id
      title
      featuredImage {
        url
      }
      variantsCount {
        count
      }
    }
  }
`;

おすすめ商品ではなく、付属商品を取得したい場合は`intent: COMPLEMENTARY`を渡してください。

```tsx
const GET_PRODUCT_RECOMMENDATIONS = `#graphql
  query GetProductRecommendations($id: ID!) {
    productRecommendations(productId: $id, intent: COMPLEMENTARY) {
      id
      title
      featuredImage {
        url
      }
      variantsCount {
        count
      }
    }
  }
`;

こちらは商品の種類を取得するクエリです。

// https://shopify.dev/docs/api/storefront/latest/queries/product
const GET_VARIANTS = `#graphql
  query GetProductVariants($productId: ID!, $first: Int!) {
    product(id: $id) {
      title
      variants(first: $first) {
        edges {
          node {
            id
            title
            price {
              amount
              currencyCode
            }
            image {
              url
            }
          }
        }
      }
    }
  }
`;

次に、query APIを使ってデータを取得する処理を追加します。後ほど全コード記載するので記載場所は省略します。

const recommendationsResponse = await query(
  GET_PRODUCT_RECOMMENDATIONS,
  {
    variables: { productId: "gid://shopify/Product/123456789" },
    version: "2025-01",
  }
);

const result = await query(GET_VARIANTS, {
  variables: {
    id: "gid://shopify/Product/123456789”,
    first: 1,
  },
  version: "2025-01",
});

productIdは直接指定していますが、実際はカートに入っている商品IDを取得して指定すると良いでしょう。以下はカート内の1番目の商品IDを取得するコードです。
useCartLines APIを使ってカート内の商品情報を取得しています。

const lines = useCartLines();
const productId = lines[0].merchandise.product.id;

ここまでできたらあとは以下の行程を追加しておすすめ商品を表示する処理を追加します。

  • 画像サムネイルを表示
  • 金額を表示
  • 複数種類がある商品は選択できるようにする
  • 数量選択ができるようにする
  • おすすめ商品の上位2件を表示する
  • 追加中はボタンをスピナーにする

完成コードは以下の通りです。適宜コンポーネントの分割などリファクタリングを行ってください。

完成コード
import {
  reactExtension,
  BlockStack,
  Text,
  useApi,
  InlineLayout,
  Button,
  useApplyCartLinesChange,
  ProductThumbnail,
  BlockLayout,
  View,
  Heading,
  BlockSpacer,
  Select,
  Stepper,
  useCartLines,
} from "@shopify/ui-extensions-react/checkout";
import { CartLineChange } from "@shopify/ui-extensions/checkout";
import { useState, useEffect } from "react";

// https://shopify.dev/docs/api/storefront/latest/queries/productRecommendations
const GET_PRODUCT_RECOMMENDATIONS = `#graphql
  query GetProductRecommendations($id: ID!) {
    productRecommendations(productId: $id) {
      id
      title
      featuredImage {
        url
      }
      variantsCount {
        count
      }
    }
  }
`;

// https://shopify.dev/docs/api/storefront/latest/queries/product
const GET_VARIANTS = `#graphql
  query GetProductVariants($id: ID!, $first: Int!) {
    product(id: $id) {
      title
      variants(first: $first) {
        edges {
          node {
            id
            title
            price {
              amount
              currencyCode
            }
            image {
              url
            }
          }
        }
      }
    }
  }
`;

export default reactExtension("purchase.checkout.block.render", () => (
  <Extension />
));

function Extension() {
  const lines = useCartLines();
  const cartLineChange = useApplyCartLinesChange();
  const { query } = useApi();

  const [recommendationProducts, setRecommendationProducts] = useState([]);
  const [productVariants, setProductVariants] = useState([]);
  const [loading, setLoading] = useState(true);
  const [expandedOptions, setExpandedOptions] = useState({});
  const [selectedVariants, setSelectedVariants] = useState({});
  const [quantities, setQuantities] = useState({});
  const [addingToCart, setAddingToCart] = useState({});

  // 商品データの取得を最適化した実装
  useEffect(() => {
    const fetchProductData = async () => {
      if (lines.length === 0) {
        setLoading(false);
        return;
      }

      try {
        setLoading(true);
        // 1番目の商品からおすすめ商品を取得
        const targetProduct = lines[0];

        // 1. おすすめ商品の取得
        const recommendationsResponse = await query(
          GET_PRODUCT_RECOMMENDATIONS,
          {
            variables: { id: targetProduct.merchandise.product.id },
            version: "2025-01",
          }
        );

        // 最大2つのおすすめ商品を取得
        const recommendedItems =
          recommendationsResponse.data.productRecommendations.slice(0, 2);
        setRecommendationProducts(recommendedItems);

        // 2. 並行してすべてのバリアント情報を取得
        const variantsData = await Promise.all(
          recommendedItems.map(async (product) => {
            const result = await query(GET_VARIANTS, {
              variables: {
                id: product.id,
                first: product.variantsCount.count || 1,
              },
              version: "2025-01",
            });
            return {
              product: result.data.product,
              recommendedProduct: product,
            };
          })
        );

        // 3. 取得したデータを整理
        const variants = variantsData.map((item) => item.product);
        setProductVariants(variants);

        // 4. 初期値の設定も並行して行う
        const initialVariants = {};
        const initialQuantities = {};

        variants.forEach((product) => {
          const firstVariantId = product.variants.edges[0]?.node?.id;
          if (firstVariantId) {
            initialVariants[product.title] = firstVariantId;
            initialQuantities[product.title] = 1;
          }
        });

        // 5. 状態を一度に更新
        setSelectedVariants(initialVariants);
        setQuantities(initialQuantities);
      } catch (error) {
        console.error("Failed to fetch product data:", error);
      } finally {
        setLoading(false);
      }
    };

    fetchProductData();
  }, []);

  // オプション展開/折りたたみ処理
  const toggleOptions = (productId) => {
    setExpandedOptions((prev) => ({
      ...prev,
      [productId]: !prev[productId],
    }));
  };

  // バリアント選択ハンドラ
  const handleVariantChange = (productId, variantId) => {
    setSelectedVariants((prev) => ({
      ...prev,
      [productId]: variantId,
    }));
  };

  // 数量変更ハンドラ
  const handleQuantityChange = (productId, quantity) => {
    setQuantities((prev) => ({
      ...prev,
      [productId]: quantity,
    }));
  };

  // 特定の商品をカートに追加(バリアントと数量を指定)
  const addToCart = async (merchandiseId, quantity = 1, productId = null) => {
    try {
      // 追加中状態を設定
      if (productId) {
        setAddingToCart((prev) => ({ ...prev, [productId]: true }));
      }

      const newCartLine: CartLineChange = {
        type: "addCartLine",
        merchandiseId,
        quantity,
      };
      await cartLineChange(newCartLine);
    } catch (error) {
      console.error("Failed to add item to cart:", error);
    } finally {
      // 追加完了後に追加中状態を解除
      if (productId) {
        setAddingToCart((prev) => ({ ...prev, [productId]: false }));
      }
    }
  };

  // 特定の商品のオプション選択からカートに追加
  const addToCartWithOptions = async (productId) => {
    const variantId = selectedVariants[productId];
    const quantity = quantities[productId] || 1;

    await addToCart(variantId, quantity, productId);

    // 追加後にオプション表示を閉じる
    setExpandedOptions((prev) => ({
      ...prev,
      [productId]: false,
    }));
  };

  if (loading) {
    return <Text>読み込み中...</Text>;
  }

  if (recommendationProducts.length === 0) {
    return null;
  }

  return (
    <View>
      {/* タイトル */}
      <Heading level={3}>こちらの商品もご一緒にいかがでしょうか?</Heading>

      <BlockSpacer />

      {/* おすすめ商品を表示 */}
      {productVariants.map((product, index) => {
        const firstVariant = product.variants.edges[0]?.node;
        if (!firstVariant) return null;

        // バリアント数の取得
        const variantCount = product.variants.edges.length;
        const productId = product.title; // 製品IDとして名前を利用

        // 現在選択されているバリアント情報
        const currentVariantId = selectedVariants[productId] || firstVariant.id;
        const currentVariant =
          product.variants.edges.find(
            (edge) => edge.node.id === currentVariantId
          )?.node || firstVariant;

        // 現在の数量
        const currentQuantity = quantities[productId] || 1;

        // 現在の商品がカートに追加中かどうか
        const isAdding = addingToCart[productId] === true;

        return (
          <BlockLayout
            key={index}
            border={"dotted"}
            padding={"tight"}
            rows={
              variantCount > 1 && expandedOptions[productId]
                ? ["auto", "auto", "auto"]
                : ["auto", "auto"]
            }
            blockAlignment={"start"}
            spacing="base"
          >
            <InlineLayout columns={["auto", "fill", "auto"]} spacing={"base"}>
              {/* 画像 */}
              <ProductThumbnail
                source={
                  currentVariant.image?.url ||
                  recommendationProducts[index].featuredImage?.url ||
                  ""
                }
              />

              {/* 商品情報 */}
              <BlockStack spacing={"none"}>
                <Text>{product.title}</Text>
                {variantCount > 1 && <Text>{currentVariant.title}</Text>}
                <Text appearance="subdued">
                  {`${Math.floor(currentVariant.price.amount)} ${
                    currentVariant.price.currencyCode
                  }`}
                </Text>
              </BlockStack>

              {/* ボタン */}
              <View padding={"none"} blockAlignment={"center"}>
                <Button
                  onPress={() =>
                    expandedOptions[productId]
                      ? addToCartWithOptions(productId)
                      : addToCart(currentVariant.id, 1, productId)
                  }
                  loading={isAdding}
                  appearance="monochrome"
                >
                  追加する
                </Button>
              </View>
            </InlineLayout>

            {/* オプション展開ボタン */}
            {variantCount > 1 && (
              <Button kind="plain" onPress={() => toggleOptions(productId)}>
                {expandedOptions[productId]
                  ? "オプションを閉じる"
                  : "オプション"}
              </Button>
            )}

            {/* オプション選択UI */}
            {variantCount > 1 && expandedOptions[productId] && (
              <BlockLayout
                spacing="base"
                padding={["none", "base", "base", "base"]}
                rows={["auto"]}
              >
                <InlineLayout columns={["fill", "fill"]} spacing="base">
                  <Select
                    label="種類を選択"
                    value={currentVariantId}
                    onChange={(value) => handleVariantChange(productId, value)}
                    options={product.variants.edges.map((edge) => ({
                      label: edge.node.title,
                      value: edge.node.id,
                    }))}
                  />

                  <Stepper
                    label="数量"
                    value={currentQuantity}
                    onChange={(value) => handleQuantityChange(productId, value)}
                    min={1}
                    max={10}
                  />
                </InlineLayout>
              </BlockLayout>
            )}
          </BlockLayout>
        );
      })}
    </View>
  );
}

プレビューで表示されるとSearch & Discoverで設定したおすすめ商品が表示されて追加できるようになります。

alt text

まとめ

今回はCheckout UI extensions を使ってチェックアウト画面に商品追加ウィジェットを作る方法を解説しました。 ぜひ、自分のストアにも導入してみて客単価の向上や売上アップにつなげてみてください。

参考