Open11

📝 Shopify Checkout UI extensions

fjmfjm

ShopifyのCheckout UI extensionsを使った開発が必要になっているのでそのスクラップです。

📝

fjmfjm

まずはアプリつくってprivateな実ストアにインストールして表示するところまでやってみた。まだ初見者なのでいろいろと勘違いがあるかもしれない。

はじめに

パートナーアカウントと開発ストアが必要なんだけど まずは Checkout UI extensionsScaffolding an extension のコマンドから実行してくれる感じで大丈夫。上記コマンドのセットアップ途中で terminal上に partner ログインしてね、とか開発ストアのURLは?とか訪ねられるのでその通りやっていけばよい。
※ ここでいう開発ストアは、パートナーダッシュボードでつくれる開発ストアのこと。

  • Checkout UI extensions のみ使いたいとしてもアプリをセットアップ流れ。アプリの一機能としてそれが有る。
  • ひとつのアプリ内で複数の extensions を管理できるので、大きく機能が変わらないならアプリは一つでOKそう
  • npm run shopify app dev したあとどうしたら..?ってなったが、terminal にでてる preview URL にアクセスして、 Preview link おしたらその extension が追加された状態で確認できる

追記

fjmfjm

開発と本番

これちょっとどうしようか迷っているのだけど、本番と開発の管理どういうのがいいんだろう。
extension の内容の修正の確認。 npm run deploy でいきなり本番切り換えず、一度ステージング環境なりでクライアントにも内容を確認してほしい場合など。

案: 本番・開発アプリを用意して、shopify.app.[env].toml をつかって切り換え

※ これから試す案です

App configuration が基本のディレクトリ構成で、shopify.app.toml ファイルにアプリ名やclient_id があるのだけど、 App structure によるとこの ファイルは複数もてて起動時に指定できるようだ。

  • ストアには開発用と本番用のアプリを用意しておく
  • ステージングストアには開発用アプリ、本番ストアには本番用アプリをインストール
  • shopify.app.toml のデフォルトは開発アプリ設定で、本番アプリ用の shopify.app.prod.toml を用意しておく

で、 node の env 見たいに切り換えできたるするんだろうか。できるならそれがよさそうだけど、

Shopify App Storeへ登録済みアプリのパートナーダッシュボード設定を保護したい - Shopify Community
本番アプリに対応する開発アプリを1つずつ作り、開発ストアもそれぞれ作ることで、そもそも --reset をする必要をほぼなくすことが可能か

とあったのでそんなに簡単にはいかないのかもしれない。
それにしても depoly コマンドで簡単に配布できるのはありがたい反面、事故がこわいないぁ。

fjmfjm

開発準備

今回 checkout.liquid をカスタマイズしているものを Checkout UI extensions に置き換えることが達成したい目標で、たとえば今こんな感じのカスタマイズをしている

  • 商品の登録内容(メタフィールド)や所属するコレクションに応じた処理
  • ログイン済の最近利用した住所を直近の5件じゃなくて登録済住所を全件候補として表示
  • お届け先住所の内容制限(文字数と利用可能な文字の制限)
  • 商品重量・価格による購入制限
  • お届け先住所や購入商品に応じたお届け候補日の入力の提供

Line Item Property や Cart Attributes を利用しているので、まずは開発ストアでチェックアウトにそのあたりを引き渡せるようにする。

商品詳細 ( main-product.liquid ) でかご追加時に Property を追加

<dl>
  <dt><label for="">lineItemProperty1</label></dt>
  <dd><input type="text" name="properties[lineItemProperty1]" value="`lineItemProperty1`" form="{{ product_form_id }}"></dd>
</dl>
<dl>
  <dt><label for="">_lineItemProperty2</label></dt>
  <dd><input type="text" name="properties[_lineItemProperty2]" value="`_lineItemProperty2`" form="{{ product_form_id }}"></dd>
</dl>

カート( main-cart-items.liquid) に Cart Attributes を追加

<dl>
  <dt>cartAttribute1</dt>
  <dd><input type="text" name="attributes[cartAttribute1]" value="`cartAttribute1`" form="cart"></dd>
</dl>
<dl>
  <dt>_cartAttribute2</dt>
  <dd><input type="text" name="attributes[_cartAttribute2]" value="`_cartAttribute2`" form="cart"></dd>
</dl>

簡易的だけどこれで properties と attributes わたせたのでそれを取得表示と、更新をやってみる。

  • カート内の商品を取得してみる
  • その商品を商品情報を StoreFront API から取得してみる
  • Cart Attributes を取得・更新してみる
import { useState, useEffect } from "react";
import {
  reactExtension,
  useApi,
  useApplyAttributeChange,
  useAttributeValues,
  useCartLines,
} from "@shopify/ui-extensions-react/checkout";

import {
  Heading,
  BlockSpacer,
  BlockStack,
  Checkbox,
  TextBlock,
  Text,
  View,
  Divider,
} from "@shopify/ui-extensions-react/checkout";


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

function App() {
  const { query } = useApi();
  const [products, setProducts] = useState([]);
  const cartLine = useCartLines();
  const cartLineString = JSON.stringify(cartLine);
  const cartLinesIds = cartLine.map((item) => item.merchandise.product.id);

  async function fetchProducts(productIds) {
    const results = [];
    const uniqueIds = Array.from(new Set(productIds));
    for (const id of uniqueIds) {
      results.push(fetchProduct(id))
    }

    const productsData = await Promise.all(results);
    setProducts(productsData);
  }

  async function fetchProduct(id) {
    try {
      const { data } = await query(
        `query {
          product(id: "${id}") {
            id
            title
            handle
            tags
          }
        }`,
      );
      return data;
    } catch (error) {
      console.error(error);
    }
    return null;
  }

  useEffect(() => {
    if (cartLinesIds && products.length === 0) {
      fetchProducts(cartLinesIds);
    }
  }, [cartLinesIds, products]);

  const [cartAttribute1, _cartAttribute2] =
    useAttributeValues([
      'cartAttribute1',
      '_cartAttribute2',
    ]);
  const applyAttributeChange = useApplyAttributeChange();

  const handleChange = async () => {
    setChecked(!checked);

    await applyAttributeChange({
      type: "updateAttribute",
      key: "cartAttribute1",
      value: "update cartAttribute1",
    });
  };

  // Render the extension components
  return (
    <BlockStack>
      <Heading>Debug</Heading>

      <View padding="base" border="base">
        <Text size="base">CartLine</Text>
        <TextBlock size="small">
          {cartLineString}
        </TextBlock>
        <BlockSpacer />
        <Text size="base">Products</Text>
        <TextBlock size="small">
          {JSON.stringify(products)}
        </TextBlock>
      </View>

      <View padding="base" border="base">
        <Text size="base">cartAttribute</Text>
        <TextBlock size="small">cartAttribute1 : {cartAttribute1}</TextBlock>
        <TextBlock size="small">_cartAttribute2: {_cartAttribute2}</TextBlock>
      </View>

      <Divider />

      <Checkbox checked={checked} onChange={handleChange}>
        Update
      </Checkbox>
    </BlockStack>
  );
}

UIとか取得・更新の方法など提供されていてありがたい..

fjmfjm

📝 Target

どこにその Extensionを出すか?の指定。
Checkout-ui-extensions targets にだいたいまとまってる。

ちなみに shopify.extensioin.toml

[[extensions.targeting]]
module = "./src/Debug.tsx"
target = "purchase.checkout.block.render"

[[extensions.targeting]]
module = "./src/Header.tsx"
target = "purchase.checkout.header.render-after"

みたいにかいておけば、ひとつの Extension で複数のブロックの管理ができる。

fjmfjm

🤔 StoreFront API でチェックアウト時の配送先の住所指定をしたい

Migrate your app だと、checkoutShippingAddressUpdateV2 はもう非推奨なので cartBuyerIdentityUpdate つかってね、ということで deliveryAddressPreferences に住所わたして、Cart APIのレスポンスの住所かわっているけど、実際チェックアウトに進んでも住所指定されてなくないか。

以下のように アドレス帳の customerAddressId 渡してAPI上のレスポンスの deliveryAddressPreferences は更新されてるっぽいけどなぁ。 これは Shipping Address とはまた別なんだろうか。

  mutation {
    cartBuyerIdentityUpdate(
      cartId: "gid://shopify/Cart/${cartToken}"
      buyerIdentity: {
        email: "${customerEmail}"
        countryCode: JP
        customerAccessToken: "${customerAccessToken}"
        deliveryAddressPreferences: [
          {
            customerAddressId: "${customerAddress.id}"
          }
        ]
      }
    ) {
      cart {
        id
        checkoutUrl
        buyerIdentity {
          email
          phone
          countryCode
          customer {
            id
          }
          deliveryAddressPreferences {
            ... on MailingAddress {
              id
              formatted
              firstName
              lastName
            }
          }
        }
      }
    }
  }

追記

いやそんなことなかった。これで配送先住所の指定できてた。
あとはチェックアウト画面でアドレス帳が引ければいいのだけど、今の Checkout UI extensions だとチェックアウト画面で CustomerAccessToken とれないよね..? AdminAPIと連携してひっぱることもできそうだけど、ちょっとセキュリティ的にアレな感じもするので、カスタムストアフロント側でチェックアウトに進む前にログイン済ならアドレス帳の候補だして選択してもらう -> cartBuyerIdentityUpdate でチェックアウトで指定住所選択済にする、という感じかなぁ。

fjmfjm

📝 Static extension targets と Block extension targets

https://zenn.dev/link/comments/2d1c64dfa61305 の続きなんだけど、

Blockごとにつくるの面倒なので、Static のみの Extension と Blockのみの Extension の定義にわけて Block 側の settings で target 指定して、その値で処理わけるしかないのかなぁ。

[extensions.settings]
[[extensions.settings.fields]]
key = "target"
type = "single_line_text_field"
name = "Block のターゲット (behavior)"
description = ""
  [[extensions.settings.fields.validations]]
  name = "choices"
  value = "[\"INFORMATION1\", \"INFORMATION2\", \"INFORMATION3\"]"
import { reactExtension, useSettings } from '@shopify/ui-extensions-react/checkout';

import { BlockStack } from '@shopify/ui-extensions-react/checkout';

import INFORMATION1 from './information/INFORMATION1';
import INFORMATION2 from './information/INFORMATION2';

const checkoutBlock = reactExtension('purchase.checkout.block.render', () => <App />);
export { checkoutBlock };

function App() {
  const { target } = useSettings();

  if (target === 'INFORMATION1') {
    return <INFORMATION1 />;
  }

  if (target === 'INFORMATION2') {
    return <INFORMATION2 />;
  }

  return <BlockStack>{target}</BlockStack>;
}
fjmfjm

📝 画面にでない Line Item Properties

You can add an underscore to the beginning of a property name to hide it from customers at checkout. For example, properties[_hiddenPropertyName].
https://shopify.dev/docs/api/liquid/objects/line_item#line_item-properties

これあるおかげで、チェックアウト画面等には表示したくないけどデータとして設定したい商品のプロパティが設定できる。具体的には商品ページで

<input type="text" name="properties[_.ItemId]" value="{{ "now" | date: "%Y%m%d%H%M%S" }}" form="{{ product_form_id }}">

のように _ 始まりで設定したプロパティは、チェックアウト画面等の商品情報には表示されないけど、APIでCart内商品引いたりするとちゃんと設定されています。

fjmfjm

🤔 不要な Cart Attributes を削除したい

できないんだ、そうか.. Liquid 側( cart.js ) ではできるそう。
ちなみに、Storefront API でもできないね。空にしようとしても must not be blank いわれて更新できないし。そのうちできるようになるかな。

📝 今の所の理解

Storefront API の cartAttributesUpdate で value に空を送った場合

  • 空文字を渡すと must not be blank エラーで更新自体失敗する
  • cartAttributesUpdate は複数の attribute の更新をまとめてリクエストできるが、value が空文字なものが 1 件でもあればエラーとなってそのリクエスト自体失敗する
  • 空文字以外にも スペース( )、全角スペース( ) もblank扱いになるようで must not be blank でる

Extension の useApplyAttributeChange で value に空文字を送った場合

  • こっちはvalue に空おくったら、 must not be blank でなくて更新できる。ただし、その attributes は削除されない。値が空になって残り続ける。
fjmfjm

📝 タイムアウト問題

  • ネットワークが極端に遅い場合などでエクステンションの読み込みがタイムアウトしてしまう場合がある ( Chromeで 3G回線で試してみたけど、結構落ちる )
  • タイムアウトした場合、自動で再読み込み等はされず、そのままエクステンション無し状態のチェックアウトのままで進めてしまう
  • バンドルサイズを軽くしたり、通信あるものはなるだけ抑えたりと工夫が必要

なんらか注文上必須な項目をエクステンション内で設定させようとしている場合は、Checkout Validation API も併用してちゃんと値が設定されたかチェックしたほうがいいですね..

ref.