2023.01.10[Tue]
[シリーズ連載] Shopify Post-Purchase Checkout Extension

Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ②

  • Shopify

目次

Webエンジニアの茄子です。
前回の記事では、公式ドキュメントのサンプルを作成するためのセットアップをしました。( https://techlab.q-co.jp/articles/69/ )
今回は、本題のPost-Purchaseページ本体とAPIを作り、追加購入できるようにします。

公式ドキュメント: https://shopify.dev/apps/checkout/post-purchase/update-an-order-for-a-checkout-post-purchase-app-extension

※ 本記事のコードは、公式ドキュメントのJavascriptのコードをTypescriptに書き換えてあります(一部JS/TS書き換えとは別の改変・修正も含まれています)。Javascriptのままで実装する場合、コードは公式ドキュメントのものをベースに進めてください。
※ パッケージによる型定義の提供が完全でないため、手元で再定義したりanyにしている部分があります。

Typescript対応

前回のgenerate extensionの時はTypescriptを選択しましたが、ベースのアプリは対応されていないのでTypescript化します。(本記事では必要なファイルのみ掲載します)
これはプロジェクトルートと web/ の両方でインストールしてください。

npm install -D typescript ts-node eslint

tsconfig.json を作成します。こちらはプロジェクトルートにのみ作成します。

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022", "dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "preserveConstEnums": true,
    "resolveJsonModule": true,
    "removeComments": false,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "isolatedModules": true,
    "noImplicitAny": false,
    "jsx": "preserve",
    "baseUrl": ".",
    "types": [ "node"],
  },
  "ts-node": {
    "esm": true
  },
  "exclude": ["node_modules"],
  "include": ["**/*.ts", "**/*.tsx"]
}

商品情報を返すサーバーの作成

Post-PurchaseページのUI作成の前に、商品情報を提供するAPIサーバーを作成します。
Post-Purchase Checkout Extension はストアのユーザー(購入者)に対しての機能であり、管理画面用のShopifyアプリのようにアプリ内で商品情報を融通してくることができません。そのため、APIサーバーが別で必要になります。

Post-Purchase Extension用のサーバーではありますが、利用するnode_modulesの関係で web/ 配下に作った方が便利です。今回は web/post-purchase/server.ts として作成します。(もちろん、extensions/~~~/配下に置いても問題ありません。その場合プロジェクトルートで必要なパッケージをインストールしてください。)

コード

必要なパッケージと型定義をインストールします。

( cd web/ )
npm install cors dotenv @types/cors @types/express @types/jsonwebtoken @types/uuid

サーバーのスクリプトを作成します。

// web/post-purchase/server.ts 
import express, { Request, Response } from "express";
import cors from "cors";
import jwt, { JwtPayload, Secret } from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
import { GraphQLClient, gql } from "graphql-request";
import { SQLiteSessionStorage } from "@shopify/shopify-app-session-storage-sqlite";
import dotenv from 'dotenv';
import path from "path";

dotenv.config()

const DB_PATH = `${path.resolve('./')}/database.sqlite`;

const sessionStorage = new SQLiteSessionStorage(DB_PATH);
export const _session = await sessionStorage.findSessionsByShop(`${process.env.DEV_STORE}.myshopify.com`);
export const session = _session[0];

const app = express();

app.use(cors());
app.use(express.json());

const PORT = process.env.PORT ?? 8077;
const API_VERSION = process.env.API_VERSION ?? "2022-10";

const endpoint = `https://${process.env.DEV_STORE}.myshopify.com/admin/api/${API_VERSION}/graphql.json`;
const accessToken = session.accessToken || process.env.SHOPIFY_ACCESS_TOKEN ||  '';

const graphQLClient = new GraphQLClient(endpoint, {
  headers: {
    "X-Shopify-Access-Token": accessToken,
  },
});

type ProductResponse = {
  product: Product;
}

type Product = {
    id: shopifyProductGlobalID;
    title: string;
    featuredImage: {
      url: string;
    }
    descriptionHtml: string;
    variants: {
      edges: {
        node: Variant;
      }[];
    }
}

type Variant = {
  id: shopifyProductVariantGlobalID;
  price: number;
  compareAtPrice: number;
}

type shopifyProductGlobalID = `gid://shopify/Product/${number}`
type shopifyProductVariantGlobalID = `gid://shopify/ProductVariant/${number}`



app.get("/offer", async (req: Request, res: Response) => {
  console.log(`/offer ${req.query} - `, new Date());
  const query = gql`
    query ($productId: ID!) {
      product(id: $productId) {
        id
        title
        featuredImage {
          url
        }
        descriptionHtml
        variants(first: 1) {
          edges {
            node {
              id
              price
              compareAtPrice
            }
          }
        }
      }
    }
  `;

  const result = await graphQLClient.request<ProductResponse>(query, {
    productId: `gid://shopify/Product/${process.env.PRODUCT_ID}`,
  });

  const product = result.product;
  const variant = result.product.variants.edges[0].node;

  const initialData = {
    variantId: variant.id.split("gid://shopify/ProductVariant/")[1],
    productTitle: product.title,
    productImageURL: product.featuredImage.url,
    productDescription: product.descriptionHtml.split(/<br.*?>/),
    originalPrice: variant.compareAtPrice,
    discountedPrice: variant.price,
  };

  res.send(initialData);
});

app.post("/sign-changeset", (req, res) => {
  console.log(`/sign-changeset ${req.query} - `, new Date());
  const decodedToken = jwt.verify(
    req.body.token,
    process.env.SHOPIFY_API_SECRET || ''
  ) as JwtPayload;
  const decodedReferenceId =
    decodedToken.input_data.initialPurchase.referenceId;

  if (decodedReferenceId !== req.body.referenceId) {
    res.status(400).send();
  }

  const payload = {
    iss: process.env.SHOPIFY_API_KEY,
    jti: uuidv4(),
    iat: Date.now(),
    sub: req.body.referenceId,
    changes: req.body.changes,
  };

  const token = jwt.sign(payload, process.env.SHOPIFY_API_SECRET || '');
  res.json({ token });
});

app.listen(PORT, () =>
  console.log(`App is listening at http://localhost:${PORT}`)
);

このファイルで実装しているエンドポイントは以下の2つです。
/offer : PRODUCT_ID で指定した商品の詳細をShopifyのAPIから取ってきて返します。
/sign-changeset : 決済情報の変更は Changeset という型が決められており、その Changesetに署名を付けるためのものです。( https://shopify.dev/api/checkout-extensions/extension-points/api#changeset )

web/package.json に、起動するスクリプトを追加します

"serve:post-purchase-api": "NODE_OPTIONS='--loader ts-node/esm' node ./post-purchase/server.ts"

セッショントークンについて

APIを利用するためにはセッショントークンが必要なのですが、ドキュメントでは

To use the curl command, you need the X-Shopify-Access-Token from your app.

とあるだけで、Shopifyアプリの方でもサーバー側で使うトークン取得方法についての解説はありません。
ストアにインストールした時に生成されているトークンがSQLiteに保存されているので、前述のサーバーのコードではそこから取り出しています。

export const _session = await shopify.findSessionsByShop(`${process.env.DEV_STORE}.myshopify.com`);

セッショントークンをログからコピーする場合

(参考 Access modes : https://shopify.dev/apps/auth/oauth/access-modes )
デフォルトのオフライントークンは、ストアとアプリが同じであれば変わらないので、ログを出したものを控えておいて使うこともできます。

res.locals.shopify.session

web/index.js で上記のように使われているところがありますので、そこでログを出力すると shpua_22e249r9r0624a29bcfdfa2eb81b31bc のようなトークンを得られます。これを .env の SHOPIFY_ACCESS_TOKEN に貼り付けて process.env から使います。

※ トークン等の認証周りは、全体のOauthフローに変更は無いもののメソッドの形式などにここ最近でも何度か変更が入っており、更新されやすい部分です。うまくいかない場合は最新の公式ドキュメントやフォーラムなどもご確認ください。

サーバー起動確認

サーバーの起動を確認します。web/ にいる状態で先程追加したコマンドを実行します。

npm run serve:post-purchase-api

別のターミナルを開き、リクエストをしてみます。

curl -v http://localhost:8077/offer

このようなJSONのレスポンスが来れば成功です。

{"variantId":"40436060586069","productTitle":"Extra Lightning Cable","productImageURL":"https://cdn.shopify.com/s/files/1/0549/4430/1141/products/lightnigng_cable_tsuruta038A4426_TP_V.jpg?v=1669287088","productDescription":["<p>世界最速の充電速度を実現!","耐久性基準一級を取得済み</p>"],"originalPrice":null,"discountedPrice":"11000"}

フロントエンド

コード

必要なパッケージをインストールします。node-fetch v3系はESM専用なのですが、今回のルートプロジェクトはESMではないので、ここでは2.6.2を使います。
(プロジェクトルートにて)

npm install node-fetch@2.6.2

コンポーネントを整えます。
( extension/tutorial-post-purchase-01/src/index.tsx )

import React, { useEffect, useState } from 'react';
import fetch from 'node-fetch';

import {
  extend,
  render,
  BlockStack,
  Button,
  CalloutBanner,
  Heading,
  Image,
  Layout,
  Text,
  TextBlock,
  TextContainer,
  Tiles,
  useExtensionInput,
  Separator,
} from "@shopify/post-purchase-ui-extensions-react";
import { PostPurchaseRenderApi } from '@shopify/post-purchase-ui-extensions/build/ts/extension-points/api/post-purchase';


 extend("Checkout::PostPurchase::ShouldRender", async ({ storage }): Promise<{ render: boolean }> => {
  console.log('Shouldrender hook');
  const postPurchaseOffer = await fetch(
    'http://localhost:8077/offer', {}
  ).then((res) => res.json());
  const render = true;

  if (render) {
    // Saves initial state, provided to `Render` via `storage.initialData`
    await storage.update(postPurchaseOffer);
  }

  return {
    render,
  };
});

render('Checkout::PostPurchase::Render', () => <App />);

export function App() {
  const {
    storage,
    inputData,
    calculateChangeset,
    applyChangeset,
    done,
  } = useExtensionInput() as PostPurchaseRenderApi;

  const [loading, setLoading] = useState(true);
  const [calculatedPurchase, setCalculatedPurchase] = useState<any>();

  useEffect(() => {
    async function calculatePurchase() {
      // Request Shopify to calculate shipping costs and taxes for the upsell
      const result = await calculateChangeset({changes});

      setCalculatedPurchase(result.calculatedPurchase);
      setLoading(false);
    }

    calculatePurchase();
  }, []);

  const {
    variantId,
    productTitle,
    productImageURL,
    productDescription,
} = storage.initialData as UpsellVariant;

const changes: AddVariantChange[] = [{type: 'add_variant', variantId, quantity: 1}];

  // calculatedPurchase から表示するための金額を取得
  // より詳しくは: node_modules/@shopify/post-purchase-ui-extensions/src/extension-points/api/post-purchase/post-purchase.ts
  const shipping: string =
    calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney
      ?.amount;
  const taxes: string =
    calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;
  const total: string = calculatedPurchase?.totalOutstandingSet.presentmentMoney.amount;
  const discountedPrice: string =
    calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney
      .amount;
  const originalPrice: string =
    calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;

  // 追加購入時の処理
  async function acceptOffer() {
    setLoading(true);

    // Make a request to your app server to sign the changeset
    const token = await fetch('http://localhost:8077/sign-changeset', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({
        referenceId: inputData.initialPurchase.referenceId,
        changes: changes,
        token: inputData.token,
      }),
    })
      .then((response) => response.json())
      .then((response) => response.token);

    // 決済情報を書き換えるリクエスト
    await applyChangeset(token);

    // Redirect to the thank-you page
    done();
  }

  //拒否時
  function declineOffer() {
    setLoading(true);
    done();
  }

  return (
    <BlockStack spacing="loose">
      <CalloutBanner>
        <BlockStack spacing="tight">
          <TextContainer>
            <Text size="medium" emphasized>
              こちらの商品もいかがでしょうか?
            </Text>
          </TextContainer>
          <TextContainer>
            <Text size="medium">こちらの画面から購入すると </Text>
            <Text size="medium" emphasized>
              15% オフ
            </Text>
          </TextContainer>
        </BlockStack>
      </CalloutBanner>
      <Layout
        media={[
          {viewportSize: 'small', sizes: [1, 0, 1], maxInlineSize: 0.9},
          {viewportSize: 'medium', sizes: [532, 0, 1], maxInlineSize: 420},
          {viewportSize: 'large', sizes: [560, 38, 340]},
        ]}
      >
        <Image description="product photo" source={productImageURL} />
        <BlockStack />
        <BlockStack>
          <Heading>{productTitle}</Heading>
          <PriceHeader
            discountedPrice={discountedPrice}
            originalPrice={originalPrice}
            loading={!calculatedPurchase}
          />
          <ProductDescription textLines={productDescription} />
          <BlockStack spacing="tight">
            <Separator />
            <MoneyLine
              label="Subtotal"
              amount={discountedPrice}
              loading={!calculatedPurchase}
            />
            <MoneyLine
              label="Shipping"
              amount={shipping}
              loading={!calculatedPurchase}
            />
            <MoneyLine
              label="Taxes"
              amount={taxes}
              loading={!calculatedPurchase}
            />
            <Separator />
            <MoneySummary
              label="Total"
              amount={total}
              // loading={!calculatedPurchase}
            />
          </BlockStack>
          <BlockStack>
            <Button onPress={acceptOffer} submit loading={loading}>
              Pay now · {formatCurrency(total)}
            </Button>
            <Button onPress={declineOffer} subdued loading={loading}>
              Decline this offer
            </Button>
          </BlockStack>
        </BlockStack>
      </Layout>
    </BlockStack>
  );
}

function PriceHeader({discountedPrice, originalPrice, loading}: {
  discountedPrice: string;
  originalPrice: string;
  loading: boolean;
}) {
  return (
    <TextContainer alignment="leading" spacing="loose">
      <Text role="deletion" size="large">
        {!loading && formatCurrency(originalPrice)}
      </Text>
      <Text emphasized size="large" appearance="critical">
        {' '}
        {!loading && formatCurrency(discountedPrice)}
      </Text>
    </TextContainer>
  );
}

function ProductDescription({textLines}: {
  textLines: string[];
}): JSX.Element {
  return (
    <BlockStack spacing="xtight">
      {textLines.map((text, index) => {
        console.log(text);
        const _text = text.replace(/<[^>]*>?/gm, '');
      return (
        <TextBlock key={index} subdued>
          {_text}
        </TextBlock>
      )
      }
      )}
    </BlockStack>
  );
}

function MoneyLine({label, amount, loading = false}: {
  label: string;
  amount: string;
  loading: boolean;
}):JSX.Element {
  return (
    <Tiles>
      <TextBlock size="small">{label}</TextBlock>
      <TextContainer alignment="trailing">
        <TextBlock emphasized size="small">
          {loading ? '-' : formatCurrency(amount)}
        </TextBlock>
      </TextContainer>
    </Tiles>
  );
}

function MoneySummary({label, amount}: {
  label: string;
  amount: string;
}):JSX.Element {
  return (
    <Tiles>
      <TextBlock size="medium" emphasized>
        {label}
      </TextBlock>
      <TextContainer alignment="trailing">
        <TextBlock emphasized size="medium">
          {formatCurrency(amount)}
        </TextBlock>
      </TextContainer>
    </Tiles>
  );
}

function formatCurrency(amount: string): string {
  if (!amount || parseInt(amount, 10) === 0) {
    return 'Free';
  }
  return ${amount}`;
}

/** パッケージからexportされていないためここで再定義。詳しくはmodule内
 *   post-purchase-ui-extensions/build/ts/extension-points/api/post-purchase
 */
type AddVariantChange = {
  type: 'add_variant';
  variantId: number;
  quantity: number;
  // discount?: ExplicitDiscount;
}

// APIからの商品情報
type UpsellVariant = {
  variantId: number;
  productTitle: string;
  productImageURL: string;
  productDescription: string[];
  originalPrice: string;
  discountedPrice: string;
}

解説

Checkout::PostPurchase::ShouldRender

このフックが render: true を返すと後述のRenderに進みます(Post-Purchaseページが挿入されます。)
inputData に(最初に)購入した商品情報が入っているので、それを判断に使えます。あるいは /offer へのリクエストで在庫がなかった場合はfalseにするといったことも可能です。

Checkout::PostPurchase::Render

ShouldRenderがtrueの場合、与えられたコンポーネントをRenderします。

Changesetを作る

最初にinitialDataに入っている商品情報(=ShouldRenderの時に取ってきた情報)を元にChangesetを構成します。後に /sign-changeset に送られます。

const changes = [{type: 'add_variant', variantId, quantity: 1}];

商品を追加した状態の送料等を計算

Shopifyが提供している関数が計算してくれます。UI上で表示する時に使います。

  useEffect(() => {
    async function calculatePurchase() {
      // Request Shopify to calculate shipping costs and taxes for the upsell
      const result = await calculateChangeset({changes});

      setCalculatedPurchase(result.calculatedPurchase);
      setLoading(false);
    }
    calculatePurchase();
  }, []);

acceptOffer

ボタンが押された時に発火します。APIに作った /sign-changeset にリクエストして署名させた後、実際に決済を上書きするリクエストをShopifyに送ります。

参考 Post-Purchase extension points API : https://shopify.dev/api/checkout-extensions/extension-points/api

確認

今回のチュートリアルでは、追加購入する・しない以外の機能はありません。この状態でPay nowボタンを押し、商品が追加されていれば完成です。

終わりに

次回はUIコンポーネントの紹介編です。

Share

  1. 01 Shopify Post-Purchase Extensionでレジ前販売を実装する 概要編 >
  2. 02 Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ① >
  3. 03 Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ②
  4. 04 Shopify Post-Purchase Extensionでレジ前販売を実装する コンポーネント紹介編 ① 〜見た目・入力〜 >
  5. 05 Shopify Post-Purchase Extensionでレジ前販売を実装する コンポーネント紹介編 ② 〜レイアウト〜 >
Shopify Post-Purchase Extensionでレジ前販売を実装する コンポーネント紹介編 ① 〜見た目・入力〜Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ①