はじめに
目標
Shopifyのカスタムアプリは、Shopify Partner, Shopify appコマンド、Shopifyストア管理画面から作成できます。Shopify appコマンドで作成すると、Shopify Partnerで認識されます。これはRemixアプリとして開発手順を確認しました。
今回は、もう一つの手法である、Shopifyストア管理画面から作成し、Shopify Partnerから作成する場合との手順の違いを確認します。
前提
- カスタムアプリは、1つのストア限定でインストールできます。
- ただし、Shopify Plusプランの組織をもつ場合、Shopify Partnerで作成したカスタムアプリは開発用ストアと同じ組織に属しているストアにもインストールできます。
- カスタムアプリから、顧客名、住所、メールアドレス、電話番号のような個人識別用情報(PII)へのAPIアクセスは、Shopify, Advanced, Plusプランでのみ有効でです。
- 管理画面から作成するアプリは、Storefront APIとAdmin APIが使用できます。
- Hydrogen, Storefront API Client, Remix Apps, Shopify API Appsなどのフレームワークがあります。
参考
- https://help.shopify.com/ja/manual/apps/app-types/custom-apps
- https://shopify.dev/docs/apps/build
- https://shopify.dev/docs/apps/build/scaffold-app
- https://shopify.dev/docs/api/storefront
- https://shopify.dev/docs/storefronts/headless/hydrogen/getting-started
学び
- 管理者向けに機能追加したい場合は、Partner画面とCLIからカスタムアプリとしてRemixアプリを作成し、Shopify管理画面のEmbedアプリとして開発する
- HydrogenはECサイト全部を独自アプリとして開発したい人向けだと理解した。RemixのサンプルコードのAPIの呼び出し方などのコーディングを参考にする
- HydrogenでEC以外のクライアント用の独自アプリを作成する
Shopifyストア管理画面
Apps and sales channelsのメニュー
- Configuration
- Admin API Configuration
- Admin API access scopes
- Webhook subscriptions
- event versionの設定
- Google Cloud Pub/Sub
- Amazon EventBridge
- Storefront API Configuration
- Admin API Configuration
- API Credentials
- Admin API access token
- API key and secret key
- App settings
- App name
- Primary contact
Hydrogenを試す
Hydrogenアプリをインストール
アプリ作成
% npm init @shopify/hydrogen
> npx
> create-hydrogen
? Connect to Shopify:
✔ Link your Shopify account
? Select a shop to log in to:
✔ xxxxx (xxxxx.myshopify.com)
╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Shopify authentication complete │
│ │
│ You are logged in to xxxxx as xxxxx@example.com │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
? New storefront name:
✔ xxxxx
? Select a language:
✔ TypeScript
? Select a styling library:
✔ Tailwind (v4 alpha)
? Install dependencies with npm?
✔ Yes
? Create a global `h2` alias to run commands instead of `npx shopify hydrogen` ?
✔ Yes
╭─ info ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ You'll need to restart your terminal session to make `h2` alias available. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ xxxxx is ready to build. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
? Do you want to scaffold routes and core functionality?
✔ Yes, set up now
? Select a URL structure to support multiple markets:
✔ Subfolders (example.com/fr-ca/...)
╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Storefront setup complete! │
│ │
│ Shopify: xxxxx │
│ Language: TypeScript │
│ Styling: Tailwind (v4 alpha) │
│ Markets: Subfolders │
│ Routes: │
│ • Home (/ & /:catchAll) │
│ • Page (/pages/:handle) │
│ • Cart (/cart/* & /discount/*) │
│ • Products (/products/:handle) │
│ • Collections (/collections/*) │
│ • Policies (/policies & /policies/:handle) │
│ • Blogs (/blogs/*) │
│ • Account (/account/*) │
│ • Search (/search) │
│ • Robots (/robots.txt) │
│ • Sitemap (/sitemap.xml) │
│ │
│ Next steps │
│ │
│ • Run `cd xxxxx && npm run dev` │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
実行
% npm run dev
> xxxxx@2024.7.5 dev
> shopify hydrogen dev --codegen
Environment variables injected into MiniOxygen:
PUBLIC_STOREFRONT_ID from Oxygen
PUBLIC_STOREFRONT_API_TOKEN from Oxygen
PUBLIC_STORE_DOMAIN from Oxygen
PRIVATE_STOREFRONT_API_TOKEN from Oxygen
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID from Oxygen
PUBLIC_CUSTOMER_ACCOUNT_API_URL from Oxygen
SHOP_ID from Oxygen
SESSION_SECRET from Oxygen
➜ Local: http://localhost:3000/
➜ Network: use --host to expose
➜ press h + enter to show help
╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ View xxxxx app: http://localhost:3000/ [1] │
│ │
│ View GraphiQL API browser: │
│ http://localhost:3000/graphiql │
│ │
│ View server network requests: │
│ http://localhost:3000/subrequest-profiler │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
[1] http://localhost:3000/
GET 200 render / 317ms
HEAD 200 render / 41ms
GET 200 loader /collections/for-men 258ms [($locale).collections.$handle] prefetch
GET 200 loader /collections/for-women 190ms [($locale).collections.$handle] prefetch
GET 200 loader /pages/contact 189ms [($locale).pages.$handle] prefetch
GET 200 loader /collections/for-women 9ms [($locale).collections.$handle] prefetch
GET 200 loader /collections/for-men 11ms [($locale).collections.$handle] prefetch
GET 200 loader /collections/for-men 9ms [($locale).collections.$handle] prefetch
% vi package.json
% npm run dev
> xxxxx@2024.7.5 dev
> shopify hydrogen dev --codegen
Environment variables injected into MiniOxygen:
PUBLIC_STOREFRONT_ID from Oxygen
PUBLIC_STOREFRONT_API_TOKEN from Oxygen
PUBLIC_STORE_DOMAIN from Oxygen
PRIVATE_STOREFRONT_API_TOKEN from Oxygen
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID from Oxygen
PUBLIC_CUSTOMER_ACCOUNT_API_URL from Oxygen
SHOP_ID from Oxygen
SESSION_SECRET from Oxygen
➜ Local: http://localhost:3000/
➜ Network: use --host to expose
➜ press h + enter to show help
╭─ success ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ View xxxxx app: http://localhost:3000/ [1] │
│ │
│ View GraphiQL API browser: │
│ http://localhost:3000/graphiql │
│ │
│ View server network requests: │
│ http://localhost:3000/subrequest-profiler │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
[1] http://localhost:3000/
ソース例
GraphQLの呼び出し
import {defer, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Await, useLoaderData, Link, type MetaFunction} from '@remix-run/react';
import {Suspense} from 'react';
import {Image, Money} from '@shopify/hydrogen';
import type {
FeaturedCollectionFragment,
RecommendedProductsQuery,
} from 'storefrontapi.generated';
export const meta: MetaFunction = () => {
return [{title: 'Hydrogen | Home'}];
};
export async function loader(args: LoaderFunctionArgs) {
// Start fetching non-critical data without blocking time to first byte
const deferredData = loadDeferredData(args);
// Await the critical data required to render initial state of the page
const criticalData = await loadCriticalData(args);
return defer({...deferredData, ...criticalData});
}
/**
* Load data necessary for rendering content above the fold. This is the critical data
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
*/
async function loadCriticalData({context}: LoaderFunctionArgs) {
const [{collections}] = await Promise.all([
context.storefront.query(FEATURED_COLLECTION_QUERY),
// Add other queries here, so that they are loaded in parallel
]);
return {
featuredCollection: collections.nodes[0],
};
}
/**
* Load data for rendering content below the fold. This data is deferred and will be
* fetched after the initial page load. If it's unavailable, the page should still 200.
* Make sure to not throw any errors here, as it will cause the page to 500.
*/
function loadDeferredData({context}: LoaderFunctionArgs) {
const recommendedProducts = context.storefront
.query(RECOMMENDED_PRODUCTS_QUERY)
.catch((error) => {
// Log query errors, but don't throw them so the page can still render
console.error(error);
return null;
});
return {
recommendedProducts,
};
}
export default function Homepage() {
const data = useLoaderData<typeof loader>();
return (
<div className="home">
<FeaturedCollection collection={data.featuredCollection} />
<RecommendedProducts products={data.recommendedProducts} />
</div>
);
}
function FeaturedCollection({
collection,
}: {
collection: FeaturedCollectionFragment;
}) {
if (!collection) return null;
const image = collection?.image;
return (
<Link
className="featured-collection"
to={`/collections/${collection.handle}`}
>
{image && (
<div className="featured-collection-image">
<Image data={image} sizes="100vw" />
</div>
)}
<h1>{collection.title}</h1>
</Link>
);
}
function RecommendedProducts({
products,
}: {
products: Promise<RecommendedProductsQuery | null>;
}) {
return (
<div className="recommended-products">
<h2>Recommended Products</h2>
<Suspense fallback={<div>Loading...</div>}>
<Await resolve={products}>
{(response) => (
<div className="recommended-products-grid">
{response
? response.products.nodes.map((product) => (
<Link
key={product.id}
className="recommended-product"
to={`/products/${product.handle}`}
>
<Image
data={product.images.nodes[0]}
aspectRatio="1/1"
sizes="(min-width: 45em) 20vw, 50vw"
/>
<h4>{product.title}</h4>
<small>
<Money data={product.priceRange.minVariantPrice} />
</small>
</Link>
))
: null}
</div>
)}
</Await>
</Suspense>
<br />
</div>
);
}
const FEATURED_COLLECTION_QUERY = `#graphql
fragment FeaturedCollection on Collection {
id
title
image {
id
url
altText
width
height
}
handle
}
query FeaturedCollection($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
collections(first: 1, sortKey: UPDATED_AT, reverse: true) {
nodes {
...FeaturedCollection
}
}
}
` as const;
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
fragment RecommendedProduct on Product {
id
title
handle
priceRange {
minVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
nodes {
id
url
altText
width
height
}
}
}
query RecommendedProducts ($country: CountryCode, $language: LanguageCode)
@inContext(country: $country, language: $language) {
products(first: 4, sortKey: UPDATED_AT, reverse: true) {
nodes {
...RecommendedProduct
}
}
}
` as const;
デプロイ
npx shopify hydrogen env pull
npx shopify hydrogen link
npx shopify hydrogen deploy
ログイン認証エラーへの対処
https://blog.davidwilliford.dev/how-to-fix-redirecturi-mismatch-in-shopify-hydrogen
- Shopify管理画面 > Sales channels > Hydrogen > 開発注のStorefrontアプリを選択
- ProductionのURLをコピー 例)xxxxx.o2.myshopify.dev
- Storefront Settings > Customer Account API > Application Setupで以下を追加し保存
- Callback URL
- https://xxxxx.o2.myshopify.dev/account/authorize
- Javascript Origins
- https://xxxxx.o2.myshopify.dev
- Login URL
- https://xxxxx.o2.myshopify.dev
- Callback URL
- https://xxxxx.o2.myshopify.devで表示し、ログインできることを確認する