Shopifyアプリ開発チュートリアルを実践しよう!①
- Shopify
- アプリ開発
- カスタムアプリ
- コピペ
- チュートリアル
- 実践
- 開発
目次
はじめに
1.1 記事について
テックディレクションではプロジェクトマネジメント業務に加えて、SaaS型ECプラットフォームShopifyを中心にECストアの構築および標準機能では対応できない要件を満たすアプリケーション開発も行っております。
「Shopifyアプリ開発チュートリアルを実践しよう!」シリーズではShopify公式アプリ開発チュートリアルに沿ってアプリ構築手順を解説します。
本記事は「Shopifyアプリのチュートリアルを実践しよう!」シリーズの第一弾です。
1.2 記事の対象
・Shopify公式チュートリアルを実践したい方
・TypeScriptで実装したい方
1.3 記事
この記事を読み終えるまでに、約20分です。実践しながら進める場合は1時間です。
2. 実践内容
商品ページURLのQRコード画像を作成するアプリを開発します。チュートリアルではJavaScriptで実装されていますが、今回はTypescriptで実装します!
画面一覧
・QRコード一覧画面
・QRコード作成画面
・QRコード編集画面
機能
・商品ページURLのQRコードを作成する
・作成したQRコードを一覧で表示する
・作成したQRコードを編集する
・作成したQRコードを削除する
・作成したQRコード画像をダウンロードする
・作成したQRコードの公開URLを発行する
・QRコードが読み取られた回数を表示する
3. 環境構築
3.1 Shopify CLIを使えるようにする
こちらの記事が参考になります!
3.2 ベースソースの準備をする
$ shopify app init上記コマンドでアプリソースを作成する準備を始めます。「dev-watanabe-test3」ディレクトリが作成されます。
? Get started building your app:
> Build a Remix app (recommended)
Build an extension-only app今回はバックエンドも調整ので上を選択します。
? For your Remix template, which language do you want?
JavaScript
> TypeScript今回はTypescriptで作成するためTypescriptを選択します。公式チュートリアルはJavaScriptなのでそちらのソースを使う場合はJavaScriptを選択します。
? 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:
✔ dev-watanabe-test3アプリ名(そのままディレクトリ名になる)を入力します。
╭─ info ────────────────────────────────────────────────────────────────────────────╮
│ │
│ Initializing project with `npm` │
│ Use the `--package-manager` flag to select a different package manager. │
│ │
╰───────────────────────────────────────────────────────────────────────────────────╯
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Installing dependencies with npm ...作成中…
╭─ success ─────────────────────────────────────────────────────────────────────────╮
│ │
│ dev-watanabe-test3 is ready for you to build! │
│ │
│ Next steps │
│ • Run `cd dev-watanabe-test3` │
│ • For extensions, run `shopify app generate extension` │
│ • To see your app, run `shopify app dev` │
│ │
│ Reference │
│ • Shopify docs [1] │
│ • For an overview of commands, run `shopify app --help` │
│ │
╰───────────────────────────────────────────────────────────────────────────────────╯
[1] <https://shopify.dev>ソース作成が成功すると上記のように表示されます。
cd dev-watanabe-test3ディレクトリを移動します。
3.3 利用モジュールをインストールする
$ npm install qrcodeBase64エンコードされたQRコード画像を生成できます。
$ npm install tiny-invariant例外条件を完結に記載できます。
4. アプリソース編集
schema.prisma
QRコードのデータを入れるテーブルを追加します。
// This is your Prisma schema file,
// learn more about it in the docs: <https://pris.ly/d/prisma-schema>
generator client {
provider = "prisma-client-js"
}
// Note that some adapters may set a maximum length for the String type by default, please ensure your strings are long
// enough when changing adapters.
// See <https://www.prisma.io/docs/orm/reference/prisma-schema-reference#string> for more information
datasource db {
provider = "sqlite"
url = "file:dev.sqlite"
}
model Session {
id String @id
shop String
state String
isOnline Boolean @default(false)
scope String?
expires DateTime?
accessToken String
userId BigInt?
firstName String?
lastName String?
email String?
accountOwner Boolean @default(false)
locale String?
collaborator Boolean? @default(false)
emailVerified Boolean? @default(false)
}
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())
}
上記で上書きします。
💡チュートリアルのソースを使うとアプリ起動時にエラーが発生するので上記ソースを使ってください!!!
$ npm run prisma migrate dev -- --name add-qrcode-table上記コマンドを実行してPrismaにテーブルを作成します。
$ npm run prisma studio上記コマンドでGUIでローカルテーブルを確認できます。
※この時点でまだデータは入っていないです。
QRCode.server.ts
データベースからQRコード情報を取得する処理を追加します。
$ cd app
$ mkdir modelsmodelsディレクトリを作成します。
import qrcode from "qrcode";
import invariant from "tiny-invariant";
import db from "../db.server";
interface QRCode {
id: number;
title: string;
shop: string;
productId: string;
productHandle?: string;
productVariantId?: string;
destination: string;
scans: number;
createdAt: Date;
}
interface SupplementedQRCode extends QRCode {
productDeleted: boolean;
productTitle?: string;
productImage?: string;
productAlt?: string;
destinationUrl: string;
image: string;
}
export async function getQRCode(id: number, graphql: (query: string, variables: Record<string, any>) => Promise<Response>): Promise<SupplementedQRCode | null> {
const qrCode = await db.qRCode.findUnique({
where: { id },
});
if (!qrCode) {
return null;
}
return supplementQRCode(qrCode, graphql);
}
export async function getQRCodes(shop: string, graphql: (query: string, variables: Record<string, any>) => Promise<Response>) {
const qrCodes = await db.qRCode.findMany({
where: { shop },
orderBy: { id: "desc" },
});
if (qrCodes.length === 0) {
return [];
}
return Promise.all(
qrCodes.map((qrCode: QRCode) => supplementQRCode(qrCode, graphql))
);
}
export function getQRCodeImage(id: number) {
const url = new URL(`/qrcodes/${id}/scan`, process.env.SHOPIFY_APP_URL);
return qrcode.toDataURL(url.href);
}
export function getDestinationUrl(qrCode: QRCode) {
if (qrCode.destination === "product" || !qrCode.productVariantId) {
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`;
}
async function supplementQRCode(qrCode: QRCode, graphql: (query: string, variables: Record<string, any>) => Promise<Response>) {
const qrCodeImagePromise = getQRCodeImage(qrCode.id);
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 qrCodeImagePromise,
};
}
export function validateQRCode(data: any) {
const errors: Record<string, string> = {};
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;
}
}modelsディレクトリの中にタイトルのファイルを作成します。
app.qrcodes.$id.tsx
QRコードを管理するフォームを作成します。
$ cd ../routes
$ touch [app.qrcodes.$id.tsx](<https://shopify.dev/docs/apps/build/build?framework=remix#create-a-qr-code-form>)routesディレクトリにタイトルのファイルを作成します。
import { useState } from "react";
import { json, redirect, LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import {
useActionData,
useLoaderData,
useNavigation,
useSubmit,
useNavigate,
} from "@remix-run/react";
import { authenticate } from "../shopify.server";
import {
Card,
Bleed,
Button,
ChoiceList,
Divider,
EmptyState,
InlineStack,
InlineError,
Layout,
Page,
Text,
TextField,
Thumbnail,
BlockStack,
PageActions,
} from "@shopify/polaris";
import { ImageIcon } from "@shopify/polaris-icons";
import db from "../db.server";
import { getQRCode, validateQRCode } from "../models/QRCode.server";
interface QRCode {
id: number;
title: string;
shop: string;
productId: string;
productHandle?: string;
productVariantId?: string;
destination: string;
scans: number;
createdAt: Date;
}
interface SupplementedQRCode extends QRCode {
productDeleted: boolean;
productTitle?: string;
productImage?: string;
productAlt?: string;
destinationUrl: string;
image: string;
}
interface Errors {
[key: string]: string;
}
interface ActionData {
errors?: Errors; // errors はオプション(存在する場合のみ)
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { admin } = await authenticate.admin(request);
if (params.id === "new") {
return json({
destination: "product",
title: "",
});
}
return json(await getQRCode(Number(params.id), admin.graphql));
}
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const { shop } = session;
const data: any = {
...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: 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}`);
}
export default function QRCodeForm() {
const errors = useActionData<ActionData>()?.errors || {};
const qrCode = useLoaderData<SupplementedQRCode>();
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: any = await window.shopify.resourcePicker({
type: "product",
action: "select", // customized action verb, either 'select' or 'add',
});
if (products && products.length > 0) {
const selected = products[0];
const product = {
id: selected.id,
title: selected.title,
handle: selected.handle,
variants: selected.variants.map((variant: any) => ({
id: variant.id ?? "", // undefined の場合、空文字を代入
})),
images: selected.images.map((image: any) => ({
altText: image.altText ?? "",
originalSrc: image.originalSrc,
})),
};
setFormState({
...formState,
productId: product.id,
productVariantId: product.variants[0].id,
productTitle: product.title,
productHandle: product.handle,
productAlt: product.images[0]?.altText,
productImage: product.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" });
}
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>
<BlockStack gap="500">
<Card>
<BlockStack gap="500">
<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}
/>
</BlockStack>
</Card>
<Card>
<BlockStack gap="500">
<InlineStack align="space-between">
<Text as={"h2"} variant="headingLg">
Product
</Text>
{formState.productId ? (
<Button variant="plain" onClick={selectProduct}>
Change product
</Button>
) : null}
</InlineStack>
{formState.productId ? (
<InlineStack blockAlign="center" gap="500">
<Thumbnail
source={formState.productImage || ImageIcon}
alt={formState.productAlt || ""}
/>
<Text as="span" variant="headingMd" fontWeight="semibold">
{formState.productTitle}
</Text>
</InlineStack>
) : (
<BlockStack gap="200">
<Button onClick={selectProduct} id="select-product">
Select product
</Button>
{errors.productId ? (
<InlineError
message={errors.productId}
fieldID="myFieldID"
/>
) : null}
</BlockStack>
)}
<Bleed marginInlineStart="200" marginInlineEnd="200">
<Divider />
</Bleed>
<InlineStack gap="500" 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
variant="plain"
url={qrCode.destinationUrl}
target="_blank"
>
Go to destination URL
</Button>
) : null}
</InlineStack>
</BlockStack>
</Card>
</BlockStack>
</Layout.Section>
<Layout.Section variant="oneThird">
<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>
)}
<BlockStack gap="300">
<Button
disabled={!qrCode?.image}
url={qrCode?.image}
download
variant="primary"
>
Download
</Button>
<Button
disabled={!qrCode.id}
url={`/qrcodes/${qrCode.id}`}
target="_blank"
>
Go to public URL
</Button>
</BlockStack>
</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>
);
}
💡Remixはファイルベースのルーティングをしている。
.を/で置き換えたURLとなる$の後ろが変数となり、パラメータとして渡す頃ができる
app._index.tsx
アプリを開いた時に最初に表示します。
今回は作成したQRコード一覧を表示します。
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link, useNavigate } from "@remix-run/react";
import { authenticate } from "../shopify.server";
import {
Card,
EmptyState,
Layout,
Page,
IndexTable,
Thumbnail,
Text,
Icon,
InlineStack,
} from "@shopify/polaris";
import { getQRCodes } from "../models/QRCode.server";
import { AlertDiamondIcon, ImageIcon } from "@shopify/polaris-icons";
interface EmptyQRCodeStateProps {
onAction: () => void; // onAction は引数なしで void を返す関数
}
interface QRCode {
productDeleted: boolean;
productTitle: any;
productImage: any;
productAlt: any;
destinationUrl: string;
image: string;
id: number;
title: string;
shop: string;
productId: string;
productHandle?: string;
productVariantId?: string;
destination: string;
scans: number;
createdAt: Date;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { admin, session } = await authenticate.admin(request);
const qrCodes = await getQRCodes(session.shop, admin.graphql);
return json({
qrCodes,
});
}
const EmptyQRCodeState: React.FC<EmptyQRCodeStateProps> = ({ 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>
);
const truncate = (str: string, { length = 25 } = {}) => {
if (!str) return "";
if (str.length <= length) return str;
return str.slice(0, length) + "…";
}
const QRTable: React.FC<any> = ({ 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: QRCode) => (
<QRTableRow key={qrCode.id} qrCode={qrCode} />
))}
</IndexTable>
);
const QRTableRow: React.FC<any> = ({ qrCode }) => (
<IndexTable.Row id={qrCode.id} position={qrCode.id}>
<IndexTable.Cell>
<Thumbnail
source={qrCode.productImage || ImageIcon}
alt={qrCode.productTitle}
size="small"
/>
</IndexTable.Cell>
<IndexTable.Cell>
<Link to={`qrcodes/${qrCode.id}`}>{truncate(qrCode.title)}</Link>
</IndexTable.Cell>
<IndexTable.Cell>
{qrCode.productDeleted ? (
<InlineStack align="start" gap="200">
<span style={{ width: "20px" }}>
<Icon source={AlertDiamondIcon} tone="critical" />
</span>
<Text tone="critical" as="span">
product has been deleted
</Text>
</InlineStack>
) : (
truncate(qrCode.productTitle)
)}
</IndexTable.Cell>
<IndexTable.Cell>
{new Date(qrCode.createdAt).toDateString()}
</IndexTable.Cell>
<IndexTable.Cell>{qrCode.scans}</IndexTable.Cell>
</IndexTable.Row>
);
export default function Index() {
const { qrCodes } = useLoaderData<typeof loader>();
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>
);
}qrcodes.$id.tsx
URLパラメータで指定された QRコードを DB から探して、画像付きでページに表示します。このURLはアプリを立ち上げている間は公開できます。
$ cd ../routes
$ touch app.qrcodes.$id.tsximport { json, LoaderFunctionArgs } 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 }: LoaderFunctionArgs) => {
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<typeof loader>();
return (
<>
<h1>{title}</h1>
<img src={image} alt={`QR Code for product`} />
</>
);
}qrcodes.$id.scan.tsx
QRコードが読み込まれた回数をカウントします。
$ touch qrcodes.$id.scan.tsximport { redirect, LoaderFunctionArgs } 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 }: LoaderFunctionArgs) => {
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));
};5. アプリ起動
$ npm run dev
> dev
> shopify app dev
? Which store would you like to use to view your project? Type to search...
✔ td-watanabe
自分のテストストアを選択してください。
? Have Shopify automatically update your app's URL in order to create a preview experience?
┃ Current app URL
┃ • https://example.com/
┃
┃ Current redirect URLs
┃ • https://example.com/api/auth
> (y) Yes, automatically update
(n) No, neverテストアプリであれば自動更新をおすすめします。自動更新設定をすれば都度アプリURLを更新する手間が省けます!

インストールします。

アプリのトップページ(QRコード一覧)が表示されました!!!「Create QR code」をクリックしてみましょう。

QRコード作成画面が表示されました!!!(画像内のQRコード部分はサイト公開時点で読み込めないので隠していますが、QRコード自体は表示されています)
ブラウザバーに注目するとapp/qrcodes/newとなっています。新規作成の場合、qrcodes/の後ろは「new」が表示されます。
テストデータを作成してみましょう。

タイトルを入力して商品を選択したら保存ボタンをクリックしてみましょう。

QRコードが作成されました!!!
ブラウザバーに注目するとapp/qrcodes/1となっています。qrcodes/の後ろはQRコードテーブルに保存されたidが表示されます。

一覧ページを再度表示すると先ほど作成したQRコードが表示されました!!!試しにスマホでQRコードを読み込んでみましょう。

スキャン回数が増えています!!!(画像は2回読み込んだ後です
参考
まとめ
今回はShopfiy公式アプリ開発チュートリアルを実行してみました。案外簡単にアプリを立ち上げることができると感じていただければ幸いです。このほかにもCheckoutアプリやCustomer Accountアプリもあるので、そちらもやっていきたいと思います。
著者:とんとろ
弊社では、ECサイトのリプレース案件から、Shopifyカスタムアプリ開発、保守案件に至るまで、EC中心にプロジェクトの質にこだわり、お客様に笑顔になってもらえるよう日々邁進しております。
皆様からのお問い合わせ・ご相談をお待ちしております。
