[2023年 Remix版] Shopifyアプリ開発公式チュートリアル - 徹底解説!

[2023年 Remix版] Shopifyアプリ開発公式チュートリアル - 徹底解説!

2023年8月の Shopify Edition Summer’23 にて、Shopifyアプリ開発のテンプレートがRemixフレームワークに変更されました。

今後もExpressやRubyと共存していくのかと思いきや、Shopify CLIから構築できるテンプレートはRemix一択になっています。

2022年11月にRemixとShopifyが提携し、実質Shopify開発専用のフレームワークとなりつつあるRemix。

今後、Shopifyアプリ開発エンジニアも、ShopifyカスタムストアフロントエンジニアもRemixで開発せざるを得ない状況になるかもしれません。

そこで、今回はRemixフレームワークでのShopify公式ドキュメントを解説していきたいと思います。

💡 推定完了時間

3時間

学べること

このチュートリアルでは、次のことを学べます。

  • Shopify CLI を使用するRemixアプリの構築方法
  • テストストアにアプリをインストールする方法
  • アプリを通して、商品を作成する方法

前提条件

  • Shopifyのパートアカウントとテストストアを作成済み
  • Node.js 16 以降がインストールされている。
  • Node.js パッケージマネージャー(npm, yarn)がインストールされている
  • Git 2.28 以降がインストールされている

公式ドキュメント

https://shopify.dev/docs/apps/getting-started/build-qr-code-app?framework=remix

 

では、早速解説していきます!

Shopifyのアプリ開発の世界へようこそ🌍🌎!!!

 

Shopifyアプリとは?

Shopifyアプリはストアに機能を拡張したり、顧客に独自の購入体験を作成したりするアプリを構築できます。Shopifyストアのデータをアプリ、プラットフォームに利用することもできます。

マーチャントは、特定のニーズに合わせるためにShopifyアプリを使用して、ビジネスの構築、外部サービスとの統合、Shopify管理者への機能の追加を支援します。

A diagram illustrating the relationships between apps, merchants, developers, and Shopify

引用:https://shopify.dev/apps/getting-started

Developer(アプリ開発者)とMerchant(ストアの責任者)の関係は、アプリ開発者が提供するアプリをストアの責任者がインストールすることから始まります。ストアの責任者は自身のストアにアプリを通して追加機能を提供し、お客様の購買体験を向上させます。

 

Shopifyアプリの環境構築

Shopifアプリの環境構築は、非常にシンプルです。このブログでは、Mac OS を前提に解説しますので、Windowsをご利用の方はShopifyドキュメントの参照をお願いいたします。

Shopify CLI をインストール

$ brew tap shopify/shopify
$ brew install shopify-cli
$ shopify version
=> 3.24.1

もし、すでにShopify CLI をインストール済みの方はバージョンが3系になっていることを確認しください。2系になっている方は、アップデートしましょう!

※ バージョンが2系になっていない場合
$ brew update
$ brew upgrade shopify-cli
$ brew unlink shopify-cli && brew link --force --overwrite shopify-cli

Remixフレームワークとは

Remixは、主にReactをベースとしたモダンなWebアプリケーションを開発するためのフレームワークです。Shopifyと正式に提携したこのフレームワークは、Shopify公式のフレームワークといえます。フレームワークは、Web開発における多くの現代的なベストプラクティスとパターンを採用しています。

主な特徴

  1. サーバーサイドレンダリング(SSR): Remixは、サーバーサイドレンダリングを容易にするための豊富なツールと設定を提供します。これにより、パフォーマンスとSEOが向上します。

  2. ネストされたルーティング: Remixでは、ファイルベースのルーティングがあり、ネストされたルーティングをサポートしています。これにより、大規模なアプリケーションでも管理が容易です。

  3. データのプリフェッチ: ユーザーがリンクにマウスを合わせるだけで、必要なデータをプリフェッチ(事前取得)します。これにより、ページ遷移が非常に高速になります。

  4. 強力なデータフェッチング: Remixは、各ルートで必要なデータを効率的にロードする仕組みを提供しています。

  5. コードスプリッティング: 効率的なコードスプリッティングが可能で、これによりパフォーマンスが向上します。

  6. TypeScript対応: 型安全性を求める開発者にとっては、TypeScriptのサポートがあります。

使用場面

  • 高パフォーマンスなWebサイトやアプリケーション
  • SEOを重視したプロジェクト
  • 大規模で複雑なアプリケーション
  • APIと密接に連携するWebアプリケーション

Remixは、短期間で多くの人気を博しており、特にReactの経験がある開発者にとっては、高度なWebアプリケーションを効率よく構築するための強力なツールとなっています。

Remixを使用してShopifyアプリを構築する

まずは、どのようなアプリを開発するか確認しましょう。

このチュートリアルでは、商品のQRコードを作成するアプリを開発します。QRコードをスキャンすると、お客様は製品が登録された購入画面、または商品詳細ページに移動します。

また、アプリはQRコードがスキャンされるたびにログを記録し、どのくらい利用されたかをマーチャントに公開します。

 

 

このチュートリアルを通して次のことを学習します。

学習内容

さて、概要を把握したところで早速開発に取り掛かりましょう。

まずはターミナルで次のコマンドを入力し、Remix版のShopifyアプリテンプレートをインストールします。

$ npm init @shopify/app@latest
 ?  Your project name?: プロジェクト名
 ?  Get started building your app: 
 ✔ Start with Remix (recommended)

最新版のShopifyテンプレートでは、Remixフレームワークしか選択できなくなっています。

また、データベース(SQLite)へのアクセスはSQLを書いていたものから prisma に変更されています。

まずは、インストールしたアプリの起動確認を行います。

$ cd プロジェクト名
$ npm run dev
?  Create this project as a new app on Shopify?
>  **(y) Yes, create it as a new app**
   (n) No, connect it to an existing app
?  App name: 
> **remix-tutorial(アプリ名)**
?  Which store would you like to use to view your project?
> **※必ずテストストアを選択してください**
?  Have Shopify automatically update your app's URL in order to create a preview experience?

   ┃  Current app URL
   ┃  • <https://shopify.dev/apps/default-app-home>
   ┃
   ┃  Current redirect URLs
   ┃  • <https://shopify.dev/apps/default-app-home/api/auth>

>  **(y) Yes, automatically update**
   (n) No, never

初期起動時にインストールするストアを聞かれますが、 必ずテストストアを選択してください。選択したストアは本番環境として利用できなくなります。

今回はパートナーダッシュボードに新しいアプリ「remix-tutorial」を作成し、アプリURLも自動でプレビューURLに更新する設定にしました。

ターミナルに表示されている Preview URL をクリックしてください。

Preview URL

クリックすると、テスト用のストア管理画面にアプリのインストール画面が表示されます。

Shopifyアプリのインストール

インストールが完了すると、次のようなアプリが表示されます。

初期画面

インストールされたアプリは、各ドキュメントへのリンクと「Generate a Product」ボタンで新しいデモ用の商品を作成する機能を持っています。

ボタンをクリックすると、動作したGraphQL API のコードが表示され、商品が新しく登録されます。

商品登録完了画面

これでアプリの初期構築が完了です!🎉

お疲れ様でした。

では、早速本題のチュートリアルを解説していきます!

1. QRコードデータモデルをデータベースに追加する

💡 注意!

prismaおよび@prisma/client モジュールは、4系がインストールされています。 最新版は5系ですが、必ず4系を使ってください。

QR コードを保存するには、テンプレートに含まれるデータベースにテーブルを追加する必要があります。

今回作成する QRCodeテーブル の仕様は次のとおりです。

  • id: テーブルの主キー。
  • title: アプリのユーザーが指定した QR コードの名前。
  • shop:QRコードを所有する店舗。
  • productId:このQRコードが該当する商品です。
  • productHandle:QRコードのリンク先URLを作成するために使用します。
  • productVariantId:QRコードのリンク先URLを作成するために使用します。
  • destination:QRコードの送信先です。
  • scans:QRコードを読み取った回数。
  • createdAt:QRコードが作成された日時。

QRCodeモデルには、アプリが Shopify製品およびバリアントデータを取得するために使用するキーが含まれています。

実行時に、追加の製品プロパティとバリアントプロパティが取得され、UI に設定するために使用されます。

さて、通常であれば PostgresQL や MySQL 上でテーブルを直接作成しますが、今回はORM(Object-relational mapping)のPrismaを利用します。

prisma/schema.prisma を開いて QRCodeモデル を定義します。

//...省略
model QRCode {
  id               Int      @id @default(autoincrement())
  title            String
  shop             String
  productId        String
  productHandle    String
  productVariantId String
  destination      String
  scans            Int      @default(0)
  createdAt        DateTime @default(now())
}

Prismaを使えば、直接SQLを書かなくてもモデルを定義することでテーブルを作成することができます。

テーブルの作成には次のコマンドを入力します。

$ npm run prisma migrate dev -- --name add-qrcode-table

これでテーブルの作成が完了です!

すごく簡単なので、本当に作成されたか確認したい場合は次のコマンドを入力します。

$ npm run prisma studio

そうすると、自動的にブラウザ開きます。もし開かない場合は http://localhost:5555/ にアクセスします。

Prisuma Studio Model

二つのモデルが登録されていることを確認できます。

QRCodeモデルをクリックしてみてください。

Prisuma Studio Table

テーブルのカラム名が表示されていることがわかります。

このように、Prismaを使えばなんのモデルが登録されているかを確認することや、テーブルのカラムの確認、データの保存・更新・削除の操作などを簡単に行うことができます。

ツールをインストールすることなく簡単に使えるのはとても魅力的ですね!

2. QRコードと商品データを取得する

データベースを作成した後、テーブルからデータを取得するコードを追加します。

データベース内からQRコードデータを取得し、GraphQL APIを通して製品情報を補完します。

まずはモデルを作成します。

ただし、ここで言う モデル とは、Prismaのスキーマ定義のモデルとは少し異なります。

MVC(Model-View-Controller)モデルの M を指しており、アプリケーションのビジネスロジックとデータを管理します層を定義します。

データベースとのやり取りや、データの処理、バリデーションなどがこの層で行われます。

QRコードを取得して検証するためのモデルを /app/models フォルダを作成し、その中に QRCode.server.js と言うファイルを作成します。

$ mkdir app/models
$ touch app/models/QRCode.server.js

ここで1つ注意しておきたいのは、ファイル名の規則です。

xxx.server.js というファイル名は、Remixにファイル内のコードがブラウザに入らないよう指示します。

つまり、「サーバーサイドのみで動作するファイルとして指定する」ということです。

これは、データベースへのリクエストや秘密鍵など公開したくない情報を書くために必要なファイルとなります。

誤って秘密鍵をフロントエンドに書かないよう注意してください!!!(何度言っても書く人が後を絶えません…)

次にQRコードの生成モジュール qrcode と、 loaderが簡単にエラーを throw できるように tiny-invariant をインストールします。

$ npm i qrcode tiny-invariant

作成した app/models/QRCode.server.js ファイルにQRコードを取得するコードを書いていきます。

import db from "../db.server";
import qrcode from "qrcode";
import invariant from "tiny-invariant";

// QRCodeのデータをDBから取得し、QRCodeデータ、商品情報、リンク先のURLを返す
export async function getQRCode(id, graphql) {
  const qrCode = await db.qRCode.findFirst({ where: { id } });

  if (!qrCode) {
    return null;
  }

  return supplementQRCode(qrCode, graphql);
}
// 複数のgetQRCodeを返す
export async function getQRCodes(shop, graphql) {
  const qrCodes = await db.qRCode.findMany({
    where: { shop },
    orderBy: { id: "desc" },
  });

  if (qrCodes.length === 0) return [];

  return Promise.all(
    qrCodes.map((qrCode) => supplementQRCode(qrCode, graphql))
  );
}
// URLからQRコードの画像データを生成する
export function getQRCodeImage(id) {
  const url = new URL(`/qrcodes/${id}/scan`, process.env.SHOPIFY_APP_URL);
  return qrcode.toDataURL(url.href);
}
// 商品詳細ページURLまたはチェックアウトURLを生成する
export function getDestinationUrl(qrCode) {
  if (qrCode.destination === "product") {
    return `https://${qrCode.shop}/products/${qrCode.productHandle}`;
  }

  const match = /gid:\/\/shopify\/ProductVariant\/([0-9]+)/.exec(qrCode.productVariantId);
  invariant(match, "Unrecognized product variant ID");

  return `https://${qrCode.shop}/cart/${match[1]}:1`;
}
// QRCodeのデータから、Graphql APIを通して商品情報を取得し、リンク先URLを含めて返却する
async function supplementQRCode(qrCode, graphql) {
  const response = await graphql(
    `
      query supplementQRCode($id: ID!) {
        product(id: $id) {
          title
          images(first: 1) {
            nodes {
              altText
              url
            }
          }
        }
      }
    `,
    {
      variables: {
        id: qrCode.productId,
      },
    }
  );

  const {
    data: { product },
  } = await response.json();

  return {
    ...qrCode,
    productDeleted: !product?.title,
    productTitle: product?.title,
    productImage: product?.images?.nodes[0]?.url,
    productAlt: product?.images?.nodes[0]?.altText,
    destinationUrl: getDestinationUrl(qrCode),
    image: await getQRCodeImage(qrCode.id),
  };
}
// QRCodeのバリデーションを実装する
export function validateQRCode(data) {
  const errors = {};

  if (!data.title) {
    errors.title = "Title is required";
  }

  if (!data.productId) {
    errors.productId = "Product is required";
  }

  if (!data.destination) {
    errors.destination = "Destination is required";
  }

  if (Object.keys(errors).length) {
    return errors;
  }
}

ここで定義した各関数の役割を確認していきます。

  • getQRCode, getQRCodes : 単一のQRコードを取得する関数と、複数のQRコードを取得する関数。具体的な返り値は supplementQRCode の返り値になる。
  • getQRCodeImage : qrcodeパッケージを使用して Base 64 でエンコードされたQRコード画像を返す。
  • getDestinationUrl : QRコードをスキャンすると、お客様は次のいずれかに遷移する。「商品詳細ページ」「商品をカートに入れた状態でのチェックアウト」。マーチャントが選択した宛先に応じて、条件付きでこの URL を構築する関数を作成する。
  • supplementQRCode : QRCodeテーブル には商品情報は含まれていません。そのため、Shopify Admin GraphQL APIを使って、商品タイトル、最初の注目商品画像の URL と代替テキストをクエリする関数を使って、商品情報を取得します。また、作成したgetDestinationUrlおよびgetQRCodeImage関数を使用してリンク先 URL の QR コード画像を取得し、QR コード情報と商品情報を含むオブジェクトを返す。
  • validateQRCode : タイトル、商品ID、宛先のバリデーション関数。

これで、QRCodeに関連するモデルの作成が完成しました!

3. QRコードのフォームを作成する

アプリユーザーが QR コードを管理できるフォームを作成します。

このフォームを作成するには Remix ルート、Polaris コンポーネント、およびApp Bridgeを使用します。

まずは、QRコードフォームページ app.qrcodes.$id.jsx を作成します。

$ touch app/routes/app.qrcodes.\$id.jsx

ここで$マークの前にバックスラッシュは、ファイル名から$idが消えてしまうのを防ぐためにつけています。

ところで、ファイル名に「.(ドット)」や「$」が入っているのには特別な理由があります。

Remixフレームワークでは、ファイルベースルーティングを採用しています。

ファイルベースルーティングとは、URLが /app/users の場合、 app.users.jsx ファイルが読み込まれ、ファイル名とルーティングが一致する方法のことです。

つまり、「.(ドット)」には階層を分ける意味があります。

Remixフレームワークのレンダリングでは、「.(ドット)」でただ階層を分けるだけでなく親レイアウトルートの読み込みも行います。

先ほどの例だと app/usersがURLの場合、 app.jsx ファイルも app.users.jsx ファイルも読み込むわけです。

これにより、 app.jsx ではユーザーの認証機能を実装し、 app.users.jsx ファイルではプロフィールページを実装するなど、ファイルごとに役割を分けることが可能となります。

一方、「$」は、動的セグメントを意味します。

このチュートリアルでは、 idパラメータが new の場合には、QRコードの新規作成ページ。 1 などのQRコードIDの場合は、編集ページとして利用します。

これは、RemixがそのセグメントのURL内の任意の値と一致し、それをアプリに提供することを意味します。

まずは、 app.qrcodes.$id.jsx ファイル内でサーバーサイドの実装を行います。

import { json } from "@remix-run/node";
import { getQRCode } from "~/models/QRCode.server";
import { authenticate } from "~/shopify.server";

export async function loader({ request, params }) {
  const { admin } = await authenticate.admin(request);
  if (params.id === "new") {
    return json({
      destination: "product",
      title: "",
    });
  }
  return json(await getQRCode(Number(params.id), admin.graphql));
}

loader関数は、Remixにとって特別な関数です。

HTTPリクエストが来た時、サーバーサイドで loader 関数が実行されます。

つまり、サーバーサイドの記述は loader 関数の中で行われると言うことです。

とてもよく似た関数で、 action関数もあります。

action関数 は、HTTPメソッドがGETメソッド以外の時にサーバーサイドで実行され、かつ loader 関数よりも先に実行されます。これは、フォームの送信などページの読み込み以外で使用される関数です。

loader 関数の中では、ユーザーの認証が行われています。またidパラメータの値によって、ブラウザに返却するJSONを条件分岐しています。

JSONを返却するために、 json関数 が使われています。 json 関数は次の機能を持ちます。

json(JSONデータ)
↓同じ意味
new Response(文字列化したJSONデータ, { 
	headers: { 
		"Content-Type": "application/json; charset=utf-8", 
		"status": 200 
	} 
});

次に、フロントエンド(View)の構築を行います。

まずは、フォームのステートを管理するためのコードを構築します。

// loader関数の上に追加
import { useActionData, useLoaderData, useNavigate, useNavigation, useSubmit } from "@remix-run/react";
import { useState } from "react";

// ...省略

// フロントエンドの構築(ステート管理のみ)
export default function QRCodeForm() {
  const errors = useActionData()?.errors || {};

  const qrCode = useLoaderData();
  const [formState, setFormState] = useState(qrCode);
  const [cleanFormState, setCleanFormState] = useState(qrCode);
  const isDirty = JSON.stringify(formState) !== JSON.stringify(cleanFormState);

  const nav = useNavigation();
  const isSaving =
    nav.state === "submitting" && nav.formData?.get("action") !== "delete";
  const isDeleting =
    nav.state === "submitting" && nav.formData?.get("action") === "delete";

  const navigate = useNavigate();

  async function selectProduct() {
    const products = await window.shopify.resourcePicker({
      type: "product",
      action: "select", // customized action verb, either 'select' or 'add',
    });

    if (products) {
      const { images, id, variants, title, handle } = products[0];

      setFormState({
        ...formState,
        productId: id,
        productVariantId: variants[0].id,
        productTitle: title,
        productHandle: handle,
        productAlt: images[0]?.altText,
        productImage: images[0]?.originalSrc,
      });
    }
  }

  const submit = useSubmit();
  function handleSave() {
    const data = {
      title: formState.title,
      productId: formState.productId || "",
      productVariantId: formState.productVariantId || "",
      productHandle: formState.productHandle || "",
      destination: formState.destination,
    };

    setCleanFormState({ ...formState });
    submit(data, { method: "post" });
  }
}

Remixの特別な関数を簡単に解説します。

  • useLoaderData : loader 関数の返り値を取得します。
  • useActionData : action 関数の返り値を取得しなす。
  • useNavigation : ページの状態(submittingなど)を管理します。
  • useNavigate: ページ遷移を管理します。
  • useSubmit : フォームを送信するための関数を返します。

このフォームのステート管理のコードが何を意味するかを解説します。

  • errors : ユーザーが QR コード フォーム フィールドのすべてに入力していない場合、 validateQRCode 関数の返り値を取得。
  • formState : 入力フォームのステート。
  • cleanFormState : フォームの初期状態。 useLoaderData の返り値を代入。
  • isDirty : フォームが変更されたかどうかを判断する。
  • isSaving , isDeleting : useNavigation 関数を使って、ページの状態を管理します。

selectProduct 関数の次のコードに着目してください。

await window.shopify.resourcePicker

これは、AppBridgeの resourcePicker 関数 を利用しています。

resourcePicker 関数は、ユーザーが 1 つ以上の製品、コレクション、または製品バリアントを検索して選択できるようにする検索ベースのインターフェイスを提供し、選択したリソースをアプリに返します。

ResourcePicker

この関数は、 window.shopify から取得することができます。

これにより、登録されている商品情報を簡単に取得できます。

AppBrdigeは以前までコンポーネントで管理されていました。 Remixフレームワークでは、コンポーネントでの管理が廃止され、window.shopifyの関数として定義されています。window.shopifyは初期テンプレート時点でインポートされています。

さて、いよいよフォームのレイアウトを作成していきます。

Polarisコンポーネントを使用してデザインしていきます。

Polarisとは、Shopify管理者用のデザインシステムです。Polaris コンポーネントを使用すると、UI がアクセス可能で応答性が高く、Shopify Admin と一貫して表示されるようになります。

フォームのレイアウトは次のようになります。

//...省略

//loader関数の上に追加
import {
  Card,
  Bleed,
  Button,
  ChoiceList,
  Divider,
  EmptyState,
  HorizontalStack,
  InlineError,
  Layout,
  Page,
  Text,
  TextField,
  Thumbnail,
  VerticalStack,
  PageActions,
} from "@shopify/polaris";

//...省略

export default function QRCodeForm() {

  //...省略

  return (
    <Page>
      <ui-title-bar title={qrCode.id ? "Edit QR code" : "Create new QR code"}>
        <button variant="breadcrumb" onClick={() => navigate("/app")}>
          QR codes
        </button>
      </ui-title-bar>
      <Layout>
        <Layout.Section>
          <VerticalStack gap="5">
            <Card>
              <VerticalStack gap="5">
                <Text as={"h2"} variant="headingLg">
                  Title
                </Text>
                <TextField
                  id="title"
                  helpText="Only store staff can see this title"
                  label="title"
                  labelHidden
                  autoComplete="off"
                  value={formState.title}
                  onChange={(title) => setFormState({ ...formState, title })}
                  error={errors.title}
                />
              </VerticalStack>
            </Card>
            <Card>
              <VerticalStack gap="5">
                <HorizontalStack align="space-between">
                  <Text as={"h2"} variant="headingLg">
                    Product
                  </Text>
                  {formState.productId ? (
                    <Button plain onClick={selectProduct}>
                      Change product
                    </Button>
                  ) : null}
                </HorizontalStack>
                {formState.productId ? (
                  <HorizontalStack blockAlign="center" gap={"5"}>
                    <Thumbnail
                      source={formState.productImage || ImageMajor}
                      alt={formState.productAlt}
                    />
                    <Text as="span" variant="headingMd" fontWeight="semibold">
                      {formState.productTitle}
                    </Text>
                  </HorizontalStack>
                ) : (
                  <VerticalStack gap="2">
                    <Button onClick={selectProduct} id="select-product">
                      Select product
                    </Button>
                    {errors.productId ? (
                      <InlineError
                        message={errors.productId}
                        fieldID="myFieldID"
                      />
                    ) : null}
                  </VerticalStack>
                )}
                <Bleed marginInline="20">
                  <Divider />
                </Bleed>
                <HorizontalStack
                  gap="5"
                  align="space-between"
                  blockAlign="start"
                >
                  <ChoiceList
                    title="Scan destination"
                    choices={[
                      { label: "Link to product page", value: "product" },
                      {
                        label: "Link to checkout page with product in the cart",
                        value: "cart",
                      },
                    ]}
                    selected={[formState.destination]}
                    onChange={(destination) =>
                      setFormState({
                        ...formState,
                        destination: destination[0],
                      })
                    }
                    error={errors.destination}
                  />
                  {qrCode.destinationUrl ? (
                    <Button plain url={qrCode.destinationUrl} external>
                      Go to destination URL
                    </Button>
                  ) : null}
                </HorizontalStack>
              </VerticalStack>
            </Card>
          </VerticalStack>
        </Layout.Section>
        <Layout.Section secondary>
          <Card>
            <Text as={"h2"} variant="headingLg">
              QR code
            </Text>
            {qrCode ? (
              <EmptyState image={qrCode.image} imageContained={true} />
            ) : (
              <EmptyState image="">
                Your QR code will appear here after you save
              </EmptyState>
            )}
            <VerticalStack gap="3">
              <Button
                disabled={!qrCode?.image}
                url={qrCode?.image}
                download
                primary
              >
                Download
              </Button>
              <Button
                disabled={!qrCode.id}
                url={`/qrcodes/${qrCode.id}`}
                external
              >
                Go to public URL
              </Button>
            </VerticalStack>
          </Card>
        </Layout.Section>
        <Layout.Section>
          <PageActions
            secondaryActions={[
              {
                content: "Delete",
                loading: isDeleting,
                disabled: !qrCode.id || !qrCode || isSaving || isDeleting,
                destructive: true,
                outline: true,
                onAction: () =>
                  submit({ action: "delete" }, { method: "post" }),
              },
            ]}
            primaryAction={{
              content: "Save",
              loading: isSaving,
              disabled: !isDirty || isSaving || isDeleting,
              onAction: handleSave,
            }}
          />
        </Layout.Section>
      </Layout>
    </Page>
  );
}

上からどのようなコンポーネントで構成されているか確認していきます。

  1. パンくずリスト
  2. タイトルフィールド
  3. ResourcePicker
  4. Destination(宛先)オプション
  5. QRコードのプレビュー
  6. 保存ボタンと削除ボタン

Polarisを使うことで、簡単にデザインを構築することができます。

また、Polarisと共にAppBridgeも使われていることを確認できます。

  • <ui-title-bar> : AppBridgeを使ってタイトルバーを表示します。タイトルバーには、ユーザーが QR コードを作成しているのか編集しているのかを示すタイトルと、QR コード リストに戻るためのパンくずリストを表示します。
  • <Button onClick={selectProduct} id="select-product">: Button コンポーネントに、 selectProduct 関数を定義し、 AppBridge のresourcePickerを呼び出します。取得した商品情報は、 formState に保持されます。

これで、フォームレイアウトが完成しました。

Saveボタンをクリックし、hadleSave を実行することで submit 関数が実行されます。

POSTメソッドのHTTPリクエストが送信されます。

少し前でご紹介しましたが、GETメソッド以外のサーバーサイド処理は action 関数で定義することが可能です!

action 関数では、認証処理、バリデーション処理、DBへの保存処理、そしてリダイレクト処理が行われます。

実際の処理は次のとおりです。

//...省略

// loader関数より前に追記
import { json, redirect } from "@remix-run/node"; // redirectを追記
import { getQRCode, validateQRCode } from "~/models/QRCode.server"; // validateQRCodeを追記
import db from "../db.server";

// ...省略

export async function action({ request, params }) {
  const { session } = await authenticate.admin(request);
  const { shop } = session;

  /** @type {any} */
  const data = {
    ...Object.fromEntries(await request.formData()),
    shop,
  };

  if (data.action === "delete") {
    await db.qRCode.delete({ where: { id: Number(params.id) } });
    return redirect("/app");
  }

  const errors = validateQRCode(data);

  if (errors) {
    return json({ errors }, { status: 422 });
  }

  const qrCode =
    params.id === "new"
      ? await db.qRCode.create({ data })
      : await db.qRCode.update({ where: { id: Number(params.id) }, data });

  return redirect(`/app/qrcodes/${qrCode.id}`);
}

//...省略

action関数はQRコードを作成、更新、または削除を行います。

DBへの保存には、 db.server.js ファイルの db インスタンスを読み込んでいます。

Prismaを使うために、 db.server.js ファイルでPrismaClientをインスタンス化し、エクスポートしています。

これでQRコードフォームが完成です!!

サーバーを立ち上げて動作確認してください。

app/qrcodes/new

app/qrcodes/new

app/qrcodes/1

app/qrcodes/1

QRコードの生成と保存がきちんと動作していることを確認できました。

次は、QRコードの一覧表示を行います。

4. QRコードの一覧表示

アプリのユーザーが QR コードに移動できるようにするには、アプリのホームに QR コードをリストします。

QRコードを読み込むには、アプリのインデックスルート app._index.jsx で、loader関数を使います。

//最初のコード
export const loader = async ({ request }) => {
  const { session } = await authenticate.admin(request);

  return json({ shop: session.shop.replace(".myshopify.com", "") });
};

//変更後のコード
import { getQRCodes } from "~/models/QRCode.server";

export async function loader({ request }) {
  const { admin, session } = await authenticate.admin(request);
  const qrCodes = await getQRCodes(session.shop, admin.graphql);

  return json({
    qrCodes,
  });
}

事前に app/models/QRCode.server.js で定義した getQRCodes 関数を使って、すべてのQRコードのリストをJSONで返却します。

この時、QRコードのデータが一つも存在しない場合とそうでない場合とで表示内容を変更します。

QRコードのデータが存在しない場合の表示は次のとおりです。

const EmptyQRCodeState = ({ onAction }) => (
  <EmptyState
    heading="Create unique QR codes for your product"
    action={{
      content: "Create QR code",
      onAction,
    }}
    image="<https://cdn.shopify.com/s/files/1/0262/4071/2726/files/emptystate-files.png>"
  >
    <p>Allow customers to scan codes and buy products using their phones.</p>
  </EmptyState>
);
空データ時のデザイン

QRコードのデータが存在する場合のコードは次のとおりです。

const QRTable = ({ qrCodes }) => (
  <IndexTable
    resourceName={{
      singular: "QR code",
      plural: "QR codes",
    }}
    itemCount={qrCodes.length}
    headings={[
      { title: "Thumbnail", hidden: true },
      { title: "Title" },
      { title: "Product" },
      { title: "Date created" },
      { title: "Scans" },
    ]}
    selectable={false}
  >
    {qrCodes.map((qrCode) => (
      <QRTableRow key={qrCode.id} qrCode={qrCode} />
    ))}
  </IndexTable>
);

const QRTableRow = ({ qrCode }) => (
  <IndexTable.Row id={qrCode.id} position={qrCode.id}>
    <IndexTable.Cell>
      <Thumbnail
        source={qrCode.productImage || ImageMajor}
        alt={qrCode.productTitle}
        size="small"
      />
    </IndexTable.Cell>
    <IndexTable.Cell>
      <Link to={`qrcodes/${qrCode.id}`}>{truncate(qrCode.title)}</Link>
    </IndexTable.Cell>
    <IndexTable.Cell>
      {qrCode.productDeleted ? (
        <HorizontalStack align="start" gap="2">
          <span style={{ width: "20px" }}>
            <Icon source={DiamondAlertMajor} color="critical" />
          </span>
          <Text color="critical" as="span">
            product has been deleted
          </Text>
        </HorizontalStack>
      ) : (
        truncate(qrCode.productTitle)
      )}
    </IndexTable.Cell>
    <IndexTable.Cell>
      {new Date(qrCode.createdAt).toDateString()}
    </IndexTable.Cell>
    <IndexTable.Cell>{qrCode.scans}</IndexTable.Cell>
  </IndexTable.Row>
);
QRコード一覧

これでQRコード一覧ページの準備ができました。

QRコードのデータがあれば QRTable コンポーネントを読み込み、なければ EmptyQRCodeState を読み込みます。

レイアウトのコードは次のとおりです。


export default function Index() {
  const { qrCodes } = useLoaderData();
  const navigate = useNavigate();
  return (
    <Page>
      <ui-title-bar title="QR codes">
        <button variant="primary" onClick={() => navigate("/app/qrcodes/new")}>
          Create QR code
        </button>
      </ui-title-bar>
      <Layout>
        <Layout.Section>
          <Card padding="0">
            {qrCodes.length === 0 ? (
              <EmptyQRCodeState onAction={() => navigate("qrcodes/new")} />
            ) : (
              <QRTable qrCodes={qrCodes} />
            )}
          </Card>
        </Layout.Section>
      </Layout>
    </Page>
  );
}

これで、QRコードの一覧表示が完成しました!!

5. 公開QRコードルートを追加する

これまで開発したページは、すべてストア管理画面からのリクエストを処理していました。

ここでは、パブリック URL を使用して QR コードを公開し、顧客が QR コードをスキャンできるようにします。

顧客が QR コードをスキャンすると、スキャン数が増加し、顧客は宛先 URL にリダイレクトされます。

パブリックページの作成を行います。

$ touch app/routes/qrcodes.\$id.jsx

QRコードのタイトルと画像データをレンダリングします。

import { json } from "@remix-run/node";
import invariant from "tiny-invariant";
import { useLoaderData } from "@remix-run/react";

import db from "../db.server";
import { getQRCodeImage } from "~/models/QRCode.server";

export const loader = async ({ params }) => {
  invariant(params.id, "Could not find QR code destination");

  const id = Number(params.id);
  const qrCode = await db.qRCode.findFirst({ where: { id } });

  invariant(qrCode, "Could not find QR code destination");

  return json({
    title: qrCode.title,
    image: await getQRCodeImage(id),
  });
};

export default function QRCode() {
  const { image, title } = useLoaderData();

  return (
    <>
      <h1>{title}</h1>
      <img src={image} alt={`QR Code for product`} />
    </>
  );
}

ここで、 invariant 関数はtiny-invariantモジュールから読み込んでいるバリデーション関数になります。第一引数の値を参照して、存在しない場合にはエラーをスローします。

一致するIDがあった場合は、Remix機能を使用してQRコードをjsonで返します。

ユーザーは、QRコードをスキャンすると宛先URLへ遷移します。

次にこの遷移先の宛先URLへのルートを実装します。

顧客を宛先URLにリダイレクトする

QR コードがスキャンされると、顧客を宛先 URL にリダイレクトします。QR コードが使用された回数を反映するために、QR コードのスキャン数を増やすこともできます。

まず、スキャンルートを作成するために、QR コードのスキャンを処理するパブリック ルートを作成します。

$ touch app/routes/qrcodes.\$id.scan.jsx

loader 関数を使って、サーバーサイドでQRコードのスキャンをカウントさせ成功すれば宛先URLにリダイレクトさせます。

import { redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import db from "../db.server";

import { getDestinationUrl } from "../models/QRCode.server";

export const loader = async ({ params }) => {
  invariant(params.id, "Could not find QR code destination");

  const id = Number(params.id);
  const qrCode = await db.qRCode.findFirst({ where: { id } });

  invariant(qrCode, "Could not find QR code destination");

  await db.qRCode.update({
    where: { id },
    data: { scans: { increment: 1 } },
  });

  return redirect(getDestinationUrl(qrCode));
};

もし、 QRコードが見つからなければ invariant 関数でエラーをスローし、見つかれば、Prismaを使って、QRCodeテーブルの scans カラムをインクリメントします。

以上で、QRCode生成アプリが完成しました!!

問題なく動作するか確認しましょう!

動作確認

動作確認の内容は次のとおりです。

  • QRコードを生成する
  • QRコードをダウンロードする
  • QRコードの公開URLを開く
  • QRコードを編集する
  • QRコードを削除する
  • QRコードをスキャンする
  • 遷移先がチェックアウト画面になっていることを確認する
  • スキャン回数が1回になっていることを確認する

 

 

Fly.ioへデプロイ

最後に、このアプリを Fly.io へデプロイします。

fly.ioにアカウント登録していない場合は事前に登録をお願いします。

Sign Up · Fly

Fly.ioのダッシュボードにログインできたら、 flyctl コマンド をインストールします。

$ brew install flyctl

flyctl コマンドを使ってアプリをデプロイしていきます。

$ flyctl launch
	? Choose an app name (leave blank to generate one): アプリ名
	? Select Organization: Fly.ioのアカウント名 (personal)
	? Choose a region for deployment: Tokyo, Japan (nrt)
$ flyctl deploy

デプロイが成功するとドメイン(https://アプリ名.fly.dev)が発行されます。

環境変数の登録は flyctl コマンドで行います。Fly.ioのダッシュボード上でも行うことも可能です。

$ fly secrets set SHOPIFY_APP_URL=https://アプリ名.fly.dev SCOPES=write_products NODE_ENV=production SHOPIFY_API_KEY=xxxxxxxxx SHOPIFY_API_SECRET=xxxxxxxx

Shopifyパートナーダッシュボードから対象のアプリの「アプリ設定」から「アプリURL」と「許可されたリダイレクトURL」のドメインをFly.ioのドメインに切り替えます。

アプリURLの設定

これで、デプロイと設定作業が完了です!!

Fly.ioはSQLiteを使用することができますが、本番環境ではSQLiteの使用を避け、PostgreSQLやMySQL、DynamoDBなどを利用すべきです。

現在の設定は、SQLiteのままになっていますので、課題としてPostgreSQLへの変更にチャレンジしてみてください。

以上で、RemixフレームワークでのShopifyアプリ開発チュートリアルが完成です!

お疲れ様でした!

最後に

Shopifyのアプリ開発では、最低限下記の理解が必要になります。

など、本当に様々な学習が必要になります。

このチュートリアルでは、全体の概要を紹介しているだけに留まります。

よくあるトラブルとして次の例をご紹介します。

もしあなたがREST API で顧客情報を取得しようと試みても、SCOPEに read_customers を追加していたとしても権限エラーで取得できません。

これはパートナー規約に記載されている、顧客情報を取得するには事前に申請と承認が必要なためです。

たとえ、顧客情報を取得できたとしても、次は最大で50件までしか取得できない問題に気がつきます。

これは、REST APIのレート制限によりデフォルトでは50件しか取得できず、ページネーションの開発が必要になるためです。

このようにShopifyアプリ開発を行うには、本当に様々な知識が必要となります。

ブログに戻る

コメントを残す

コメントは公開前に承認される必要があることにご注意ください。