Shopifyアプリ構築環境・簡易アプリ作成-Node.jsの場合-

 

Ruby、Reactで開発をする方向けに備忘録を残します。
アプリ作成フェーズは途中ですので環境構築をしたい方のみ閲覧ください。

・Shopify公式サイト
https://shopify.dev/apps/tools/cli/getting-started

・Kumaouさんの記事
https://zenn.dev/kumao/articles/7d656d0099e876
を参考にしています。

手持ちのバージョン

macOS Big Sur 11.5.2
ruby 2.7.3p183
Rails 6.1.4.1
shopify 2.7.1
node 14.18.0

ngrok http 8081

赤枠の情報をアプリ管理>アプリ設定
アプリURLに入力

リダイレクトURLの許可の部分にも
XXX.ngrok.io/auth/shopify/callback

と入力する

Shopify CLIでアプリ作成

ターミナルで下記を入力

shopify app create node
? App name
shop_items(任意で)
? What type of app are you building? (Choose with ↑ ↓ enter filter with 'f'
? 1. Public: An app built for a side marchant audience
? 2. Custom: An app custom built for a single client //これを選択

アプリがダッシュボードで作られていることを確認する

次に該当ディレクトリにターミナルで移動、ローカルサーバを起動、

cd shop_items
shopify app serve

・
・
・
Do you want to update your application url? (Choose with ↑ ↓ ⏎)
> 1. yes
  2. no

・
・
・
⭑ To install and start using your app, open this URL in your browser:
https://{ご自身の環境で変動}.ngrok.io/auth?shop={yourShopDomain}
↑にアクセス

上記赤字のURLにアクセスすると、下記画面が出るのでインストールをクリック

インストールに成功すると埋め込みアプリが表示されます。

もしTo install and start using your app, open this URL in your browser:のURLにアクセスして下記のようなエラーが出た場合は

Running server…内で、

% curl  http://localhost:8081

と入力し、しばらーく待つと解消します。

pagesにあるindex.jsのコードを以下のように変更して変更を確かめてみます。

import { Heading, Page } from "@shopify/polaris";

const Index = () => (
  <Page>
    <Heading>アプリ開発開始!</Heading>
  </Page>
);

export default Index;

変更が確認できました^^

商品を登録する

1.新しいターミナルウィンドウを開く

2.プロジェクトディレクトリに移動

3.下記をターミナルで入力

shopify populate products
(※No store found. Please run shopify login --store=STORE to login to a specific store
とでた場合は、
hopify login --store=【ストアmyshopify前の部分まで】を入れて再度実行しましょう)

空の状態を追加する

Polarisを使用して、ユーザーインターフェイスを構築
pages/index.jsを下記のように変更

import { Heading, Page, TextStyle, Layout, EmptyState} from "@shopify/polaris";

const img = 'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';

const Index = () => (
  <Page>
    <Layout>
      <EmptyState // Empty state component
        heading="Discount your products temporarily"
        action={{
          content: 'Select products',
          onAction: () => this.setState({ open: true }),
        }}
        image={img}
      >
        <p>Select products to change their price temporarily.</p>
      </EmptyState>
    </Layout>
  </Page>
);

export default Index;

プレビューすると、空の状態が表示されます。

リソースピッカーを追加し、アプリから商品を選択できるようにします。
pages/index.jsにリソースピッカーの状態を設定するクラスを追加。ResourcePickerコンポーネントをプライマリアクションボタンに追加して更新します。

import React from 'react';
import { Heading, Page, TextStyle, Layout, EmptyState} from "@shopify/polaris";
import { ResourcePicker, TitleBar } from '@shopify/app-bridge-react';

const img = 'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';

// Sets the state for the resource picker
class Index extends React.Component {
  state = { open: false };
  render() {
    return (
      <Page>
        <TitleBar
          primaryAction={{
            content: 'Select products',
            onAction: () => this.setState({ open: true }),
          }}
        />
        <ResourcePicker // Resource picker component
          resourceType="Product"
          showVariants={false}
          open={this.state.open}
          onSelection={(resources) => this.handleSelection(resources)}
          onCancel={() => this.setState({ open: false })}
        />
        <Layout>
          <EmptyState
            heading="Discount your products temporarily"
            action={{
              content: 'Select products',
              onAction: () => this.setState({ open: true }),
            }}
            image={img}
          >
            <p>Select products to change their price temporarily.</p>
          </EmptyState>
        </Layout>
      </Page>
    );
  }
  handleSelection = (resources) => {
    this.setState({ open: false });
    console.log(resources);
  };
}

export default Index;

プレビューで、[製品の選択]をクリックすると、[製品追加]モーダルが開くようになりました。

リストを追加する

商品を取得する方法が必要です。GraphQL AdminAPIを使用して取得
アプリがGraphQLを使用してデータをクエリできるようにするには、新しいResourceList.jsファイルを作成し、ファイルにgraphql-tagreact-apolloインポートをします。

npm install store-js

ローカルストレージを管理するためのクロスブラウザJavaScriptライブラリです。

pagesの中にcomponentsフォルダを作成
その中にResourceList.jsファイルを作成します。

mkdir components
cd components
touch ResourceList.js

ResourceList.jsファイルにインポートを追加し、GraphQLクエリを設定して商品と価格を取得します。

import React from 'react';
import gql from 'graphql-tag';
import { Query } from 'react-apollo';
import {
  Card,
  ResourceList,
  Stack,
  TextStyle,
  Thumbnail,
} from '@shopify/polaris';
import store from 'store-js';
import { Redirect } from '@shopify/app-bridge/actions';
import { Context } from '@shopify/app-bridge-react';

// GraphQL query to retrieve products by IDs.
// The price field belongs to the variants object because
// variations of a product can have different prices.
const GET_PRODUCTS_BY_ID = gql`
  query getProducts($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Product {
        title
        handle
        descriptionHtml
        id
        images(first: 1) {
          edges {
            node {
              url
              altText
            }
          }
        }
        variants(first: 1) {
          edges {
            node {
              price
              id
            }
          }
        }
      }
    }
  }
`;

ResourceList.jsにGraphQLクエリと呼ばれるクラス設定ResourceListWithProducts
ResourceListコンポーネントを定義して商品と価格を取得します。

class ResourceListWithProducts extends React.Component {
  static contextType = Context;

  render() {
    const app = this.context;

    return (
      // GraphQL query to retrieve products and their prices
      <Query query={GET_PRODUCTS_BY_ID} variables={{ ids: store.get('ids') }}>
        {({ data, loading, error }) => {
          if (loading) return <div>Loading…</div>;
          if (error) return <div>{error.message}</div>;

          return (
            <Card>
              <ResourceList // Defines your resource list component
                showHeader
                resourceName={{ singular: 'Product', plural: 'Products' }}
                items={data.nodes}
                renderItem={item => {
                  const media = (
                    <Thumbnail
                      source={
                        item.images.edges[0]
                          ? item.images.edges[0].node.url
                          : ''
                      }
                      alt={
                        item.images.edges[0]
                          ? item.images.edges[0].node.altText
                          : ''
                      }
                    />
                  );
                  const price = item.variants.edges[0].node.price;
                  return (
                    <ResourceList.Item
                      id={item.id}
                      media={media}
                      accessibilityLabel={`View details for ${item.title}`}
                      onClick={() => {
                        store.set('item', item);
                      }}
                    >
                      <Stack>
                        <Stack.Item fill>
                          <h3>
                            <TextStyle variation="strong">
                              {item.title}
                            </TextStyle>
                          </h3>
                        </Stack.Item>
                        <Stack.Item>
                          <p>${price}</p>
                        </Stack.Item>
                      </Stack>
                    </ResourceList.Item>
                    );
                  }}
                />
              </Card>
            );
          }}
        </Query>
      );
    }
  }

export default ResourceListWithProducts;

pages/index.jsに、ファイルとインポートを追加し、アプリを空の状態にする定数を定義します。
次に空の状態のレイアウトを制御するコードを更新して商品のリストを指定します。

import React from 'react';
import { Page, Layout, EmptyState} from "@shopify/polaris";
import { ResourcePicker, TitleBar } from '@shopify/app-bridge-react';
import store from 'store-js';
import ResourceListWithProducts from './components/ResourceList';

const img = 'https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg';

class Index extends React.Component {
  state = { open: false };
  render() {
    // A constant that defines your app's empty state
    const emptyState = !store.get('ids');
    return (
      <Page>
        <TitleBar
          primaryAction={{
            content: 'Select products',
            onAction: () => this.setState({ open: true }),
          }}
        />
        <ResourcePicker
          resourceType="Product"
          showVariants={false}
          open={this.state.open}
          onSelection={(resources) => this.handleSelection(resources)}
          onCancel={() => this.setState({ open: false })}
        />
        {emptyState ? ( // Controls the layout of your app's empty state
          <Layout>
            <EmptyState
              heading="Discount your products temporarily"
              action={{
                content: 'Select products',
                onAction: () => this.setState({ open: true }),
              }}
              image={img}
            >
              <p>Select products to change their price temporarily.</p>
            </EmptyState>
          </Layout>
        ) : (
          // Uses the new resource list that retrieves products by IDs
          <ResourceListWithProducts />
        )}
      </Page>
    );
  }
  handleSelection = (resources) => {
    const idsFromResources = resources.selection.map((product) => product.id);
    this.setState({ open: false });
    store.set('ids', idsFromResources);
  };
}

export default Index;

クリックすると、商品を選択・追加できるようになります。

商品の価格を更新する

商品データを読み取るためのGraphQLクエリを実装し、取得した商品をリソースリストに表示する機能を追加しました。次に、GraphQLを使用して商品データを変更します。

ProductVariantUpdateアプリ内の製品の価格を更新するために呼び出されるGraphQLミューテーションを設定します。

1.componentsフォルダに新しいApplyRandomPrices.jsファイルを作成します。

touch ApplyRandomPrices.js

2.ApplyRandomPrices.jsファイルにインポートを追加し、アプリが製品の価格を更新できるようにするGraphQLミューテーションを設定します。

import React, { useState } from 'react';
import gql from 'graphql-tag';
import { Mutation } from 'react-apollo';
import { Layout, Button, Banner, Toast, Stack, Frame } from '@shopify/polaris';
import { Context } from '@shopify/app-bridge-react';

// GraphQL mutation that updates the prices of products
const UPDATE_PRICE = gql`
  mutation productVariantUpdate($input: ProductVariantInput!) {
    productVariantUpdate(input: $input) {
      product {
        title
      }
      productVariant {
        id
        price
      }
    }
  }
`;

3.ミューテーションを行った後入力内容を受け取り、選択した商品にランダムな価格を適用するApplyRandomPrices.jsというクラスを設定します。

pages / components / ResourceList.js

class ApplyRandomPrices extends React.Component {
  static contextType = Context;

  render() {
    return ( // Uses mutation's input to update product prices
      <Mutation mutation={UPDATE_PRICE}>
        {(handleSubmit, {error, data}) => {
          const [hasResults, setHasResults] = useState(false);

          const showError = error && (
            <Banner status="critical">{error.message}</Banner>
          );

          const showToast = hasResults && (
            <Toast
              content="Successfully updated"
              onDismiss={() => setHasResults(false)}
            />
          );

          return (
            <Frame>
              {showToast}
              <Layout.Section>
                {showError}
              </Layout.Section>

              <Layout.Section>
                <Stack distribution={"center"}>
                  <Button
                    primary
                    textAlign={"center"}
                    onClick={() => {
                      let promise = new Promise((resolve) => resolve());
                      for (const variantId in this.props.selectedItems) {
                        const price = Math.random().toPrecision(3) * 10;
                        const productVariableInput = {
                          id: this.props.selectedItems[variantId].variants.edges[0].node.id,
                          price: price,
                        };

                        promise = promise.then(() => handleSubmit({ variables: { input: productVariableInput }}));
                      }

                      if (promise) {
                        promise.then(() => this.props.onUpdate().then(() => setHasResults(true)));
                    }}
                  }
                  >
                    Randomize prices
                  </Button>
                </Stack>
              </Layout.Section>
            </Frame>
          );
        }}
      </Mutation>
    );
  }
}

export default ApplyRandomPrices;

4.pages/index.jsに次のインポートを含めるようimport部分を追加更新します。

import React from 'react';
import gql from 'graphql-tag';
import { Mutation } from 'react-apollo';
import { Page, Layout, EmptyState, Button, Card } from "@shopify/polaris";
import { ResourcePicker, TitleBar } from '@shopify/app-bridge-react';
import store from 'store-js';
import ResourceListWithProducts from './components/ResourceList';

5.ResourceList.jsApplyRandomPricesのインポートを追加。
ResourceListWithProductsクラスにコンストラクターを実装し、GraphQLクエリを更新して、IDによる商品の再フェッチを有効にします。最後にResourceListコンポーネントを更新します。

import React from 'react';
import gql from 'graphql-tag';
import { Query } from 'react-apollo';
import {
  Card,
  ResourceList,
  Stack,
  TextStyle,
  Thumbnail,
} from '@shopify/polaris';
import store from 'store-js';
import { Redirect } from '@shopify/app-bridge/actions';
import { Context } from '@shopify/app-bridge-react';
import ApplyRandomPrices from './ApplyRandomPrices';

// GraphQL query that retrieves products by ID
const GET_PRODUCTS_BY_ID = gql`
  query getProducts($ids: [ID!]!) {
    nodes(ids: $ids) {
      ... on Product {
        title
        handle
        descriptionHtml
        id
        images(first: 1) {
          edges {
            node {
              id
              altText
            }
          }
        }
        variants(first: 1) {
          edges {
            node {
              price
              id
            }
          }
        }
      }
    }
  }
`;

class ResourceListWithProducts extends React.Component {
  static contextType = Context;

  // A constructor that defines selected items and nodes
  constructor(props) {
    super(props);
    this.state = {
      selectedItems: [],
      selectedNodes: {},
    };
  }

  render() {
    const app = this.context;

    // Returns products by ID
    return (
        <Query query={GET_PRODUCTS_BY_ID} variables={{ ids: store.get('ids') }}>
          {({ data, loading, error, refetch }) => { // Refetches products by ID
            if (loading) return <div>Loading…</div>;
            if (error) return <div>{error.message}</div>;

            const nodesById = {};
            data.nodes.forEach(node => nodesById[node.id] = node);

            return (
              <>
                <Card>
                  <ResourceList
                    showHeader
                    resourceName={{ singular: 'Product', plural: 'Products' }}
                    items={data.nodes}
                    selectable
                    selectedItems={this.state.selectedItems}
                    onSelectionChange={selectedItems => {
                      const selectedNodes = {};
                      selectedItems.forEach(item => selectedNodes[item] = nodesById[item]);

                      return this.setState({
                        selectedItems: selectedItems,
                        selectedNodes: selectedNodes,
                      });
                    }}
                    renderItem={item => {
                      const media = (
                        <Thumbnail
                          source={
                            item.images.edges[0]
                              ? item.images.edges[0].node.id
                              : ''
                          }
                          alt={
                            item.images.edges[0]
                              ? item.images.edges[0].node.altText
                              : ''
                          }
                        />
                      );
                      const price = item.variants.edges[0].node.price;
                      return (
                        <ResourceList.Item
                          id={item.id}
                          media={media}
                          accessibilityLabel={`View details for ${item.title}`}
                          verticalAlignment="center"
                          onClick={() => {
                            let index = this.state.selectedItems.indexOf(item.id);
                            const node = nodesById[item.id];
                            if (index === -1) {
                                this.state.selectedItems.push(item.id);
                                this.state.selectedNodes[item.id] = node;
                            } else {
                              this.state.selectedItems.splice(index, 1);
                                delete this.state.selectedNodes[item.id];
                            }

                            this.setState({
                              selectedItems: this.state.selectedItems,
                              selectedNodes: this.state.selectedNodes,
                              });
                          }}
                        >
                          <Stack alignment="center">
                            <Stack.Item fill>
                              <h3>
                                <TextStyle variation="strong">
                                  {item.title}
                                </TextStyle>
                              </h3>
                            </Stack.Item>
                            <Stack.Item>
                              <p>${price}</p>
                            </Stack.Item>
                          </Stack>
                        </ResourceList.Item>
                      );
                    }}
                  />
                </Card>

              <ApplyRandomPrices selectedItems={this.state.selectedNodes} onUpdate={refetch} />
            </>
          );
        }}
      </Query>
    );
  }
}

export default ResourceListWithProducts;

アプリで、商品の価格をランダムな価格で更新できるようになりました。
(使いみちは悩ましいですが実装学習として利用できたらと思います。)