💳

【Shopify.dev和訳】Apps/Checkout/Post-purchase/Upsell example

2021/09/12に公開約21,500字

この記事について

この記事は、Apps/Checkout/Post-purchase/Upsell exampleの記事を和訳したものです。

記事内で使用する画像は、公式ドキュメント内の画像を引用して使用させていただいております。

Shopify アプリのご紹介

Shopify アプリである、「商品ページ発売予告アプリ | リテリア Coming Soon」は、商品ページを買えない状態のまま、発売日時の予告をすることができるアプリです。Shopify で Coming Soon 機能を実現することができます。

https://apps.shopify.com/shopify-application-314?locale=ja&from=daniel

Shopify アプリである、「らくらく日本語フォント設定|リテリア Font Picker」は、ノーコードで日本語フォントを使用できるアプリです。日本語フォントを導入することでブランドを演出することができます。

https://apps.shopify.com/font-picker-1?locale=ja&from=daniel

アップセルの例

このチュートリアルでは、チェックアウトエクステンションを使用して、購入後のアップセルを作成する方法を学びます。このチュートリアルは、基本的な例のチュートリアルをベースにしていますので、まずそれを完了することをお勧めします。


ステップ 1:ストアで製品を作成する

アップセルオファーで使用するためには、開発ストアに製品を用意する必要があります。

開発ストアに商品がない場合は、以下の curl コマンドを使用して商品を追加することができます。

Terminal
curl -X POST \
  https://YOUR_DEV_STORE.myshopify.com/admin/api/2021-04/products.json \
  -H 'Content-Type: application/json' \
  -H 'X-Shopify-Access-Token: YOUR_ACCESS_TOKEN' \
  --data '{
    "product": {
      "title": "Skeleton 28.5\" Cruiser Deck",
      "body_html" : "100% Canadian Maple <br/> Kicktail <br/> Low Concave <br/> Sanded Wheel Wells <br/> L: 28.5\" | W: 8\" | WB: 14.6",
      "variants": [{"price" : "119", "compare_at_price" : "139.99"}],
      "images": [{"src" : "https://cdn.shopify.com/s/files/1/0238/9304/9424/products/Landyachtz_2021_Dinghy_Skeleton_Complete_490x.progressive.jpg"}]
    }
  }'

返却されたプロダクト ID は、このチュートリアルの後半で使用します。


ステップ 2:アプリサーバーの構築

商品 ID を取得し、注文の変更を署名するには、アプリサーバーが必要です。お客様がアップセルを承諾すると、注文が更新されます。

アプリサーバーのコード例

この完全なアプリサーバーコードをアプリに使用できます。すでにアプリサーバーがある場合は、サンプルコードを使用して、このユースケースをサポートするように変更できます。

フォルダを作成し、index.jsという名前のファイルに以下のアプリサーバーコードを保存します。

index.js
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const fetch = require('node-fetch');

const app = express();
const port = process.env.PORT || 8077;

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


// 以下の定数を実数値に置き換えてください。
const DEV_STORE = 'YOUR_DEV_STORE';
const X_SHOPIFY_ACCESS_TOKEN = 'YOUR_ACCESS_TOKEN';
const API_KEY = 'YOUR_API_KEY';
const API_SECRET = 'YOUR_API_SECRET';
const PRODUCT_ID = 'YOUR_PRODUCT_ID';


app.get('/get-offer', async (req, res) => {
  const apiResponse = await fetch(
    `https://${DEV_STORE}.myshopify.com/admin/api/2021-04/products/${PRODUCT_ID}.json`,
    {
      headers: {
        'X-Shopify-Access-Token' : X_SHOPIFY_ACCESS_TOKEN
      }
    }
  );
  const jsonResponse = await apiResponse.json();

  const product = jsonResponse.product;
  res.send({
    variantId: product.variants[0].id,
    productTitle: product.title,
    productImageURL: product.images[0].src,
    productDescription: product.body_html.split(/<br.*?>/),
    discountedPrice: product.variants[0].price,
    originalPrice: product.variants[0].compare_at_price,
  });
});

app.post('/sign-changeset', (req, res) => {
  // リクエストの信憑性を検証します。
  const decodedToken = jwt.verify(req.body.token, API_SECRET);
  const decodedReferenceId = decodedToken.input_data.initialPurchase.referenceId;
  if (decodedReferenceId !== req.body.referenceId){
    res.status(400).render();
  }

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

  const token = jwt.sign(payload, API_SECRET);
  res.json({ token });
});

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

サーバーの起動

  1. 必要な依存関係をすべてインストールします。
Terminal
npm install express cors jsonwebtoken uuid node-fetch
  1. サーバーを起動します。
Terminal
node index.js

get-offer のエンドポイント

http://localhost:8077/get-offer を開くと、このチュートリアルで使用している製品を説明する JSON オブジェクトが表示されているはずです。この JSON オブジェクトは、前回のチュートリアルでハードコードされたモックデータと似ていますが、以下のような違いがあります。

  • この JSON オブジェクトには、拡張コードが製品を注文に追加する際に使用する variantId プロパティが含まれています。
  • この JSON オブジェクトには、shippingtaxestotal金額のプロパティが含まれていません。代わりに、これらの値は、配送先住所に基づいてチェックアウト時に計算されます。

sign-changeset エンドポイント

sign-changesetエンドポイントは、以下の理由で JWT トークンを使用します。

  • リクエストが Shopify から来ていることを確認する。
  • アプリケーションが注文に適用したい変更(例:商品の追加など)を署名します。Shopify は署名を使用して、変更があなたのアプリからのものであることを確認します。

ステップ 3:拡張コードの更新

拡張機能のスクリプトの内容を、以下のコードに置き換えます。このコードは、calculateChangeset API を使用して、送料と税金を計算します。また、このコードは applyChangeset API を使用して、商品を注文に追加します。

src/index.jsx
Reactのコード
import {useEffect, useState} from 'react';
import { extend, render, useExtensionInput, BlockStack, Button, CalloutBanner, Heading, Image, InlineStack, Text, TextContainer, Separator, Tiles, TextBlock, Layout } from '@shopify/post-purchase-ui-extensions-react';


extend('Checkout::PostPurchase::ShouldRender', async ({storage}) => {
  const postPurchaseOffer = await fetch('http://localhost:8077/get-offer').then(res => res.json());
  await storage.update(postPurchaseOffer);

  return { render : true };
});


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

export function App() {
  const {done, storage, calculateChangeset, applyChangeset, inputData} = useExtensionInput();
  const {variantId, productTitle, productImageURL, productDescription, discountedPrice, originalPrice} = storage.initialData;
  const [state, setState] = useState(null);

  const [shipping, setShipping] = useState(null);
  const [taxes, setTaxes] = useState(null);
  const [total, setTotal] = useState(null);

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


  useEffect(() => {
    async function updatePriceBreakdown(){
      // shopifyにアップセルのための配送料と税金の計算を依頼します。
      const result = await calculateChangeset({changes});

      // レスポンスから値を抽出
      const shipping = result.calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
      const taxes = result.calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;

      // 状態変数の更新
      setShipping(shipping);
      setTaxes(taxes);
      setTotal(`${Number(discountedPrice) + (Number(shipping) || 0) + (Number(taxes) || 0)}`);
    }

    updatePriceBreakdown();
  }, []);


  function acceptOffer(){
    async function doAcceptOrder(){
      // チェンジセットに署名するために、アプリサーバにリクエストを行います。
      const jwtToken = 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);

      // チェンジセットを適用するためにShopifyのサーバーにリクエストします。
      await applyChangeset(jwtToken);
      done();
    }

    // UIを更新するためにステートを設定し、非同期関数を呼び出します。
    setState('ACCEPTING');
    doAcceptOrder();
  }


  function declineOffer(){
    setState('DECLINING');
    done();
  }


  return (
    <BlockStack spacing="loose">
      <CalloutBanner title="This is a demo" >
        <Text size="medium">Add the {productTitle} to your order and </Text>
        <Text size="medium" emphasized>save 15%.</Text>
      </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} />
          <ProductDescription textLines={productDescription}/>
          <PriceBreakdown discountedPrice={discountedPrice} shipping={shipping} taxes={taxes} total={total} />
          <Buttons state={state} total={total} onAcceptPressed={acceptOffer} onDeclinePressed={declineOffer} />
        </BlockStack>
      </Layout>
    </BlockStack>
  )
}

function PriceHeader({discountedPrice, originalPrice}) {
  return (
  <InlineStack alignment="baseline" spacing="loose">
      <TextContainer alignment="trailing" spacing="loose">
        <Text role="deletion" size="large">{originalPrice}</Text>
        <Text emphasized size="large"> ${discountedPrice} CAD</Text>
      </TextContainer>
  </InlineStack>
  );
}

function ProductDescription({textLines}){
  return (
    <BlockStack spacing="xtight">
      {
        textLines.map((text, index) => <TextBlock key={index} subdued>{text}</TextBlock>)
      }
    </BlockStack>
  );
}

function PriceBreakdown({discountedPrice, shipping, taxes, total}) {
  if (!total) {
    return <></>;
  } else {
    return (
      <BlockStack spacing="tight">
        <Separator />
        <PriceBreakdownLine label="Subtotal" amount={discountedPrice}/>
        <PriceBreakdownLine label="Shipping" amount={shipping ?? 'Free'}/>
        <PriceBreakdownLine label="Taxes" amount={taxes ?? 'Free'}/>
        <Separator />
        <PriceBreakdownLine label="Total" amount={`$${total}`} textSize="medium"/>
      </BlockStack>
    );
  }
}

function PriceBreakdownLine({label, amount, textSize="small"}) {
  return (
    <Tiles>
      <TextBlock size="small">{label}</TextBlock>
      <TextContainer alignment="trailing">
        <TextBlock emphasized size={textSize}>{amount}</TextBlock>
      </TextContainer>
    </Tiles>
  );
}

function Buttons({state, total, onAcceptPressed, onDeclinePressed}) {
  return (
    <BlockStack>
      <Button onPress={onAcceptPressed} submit disabled={!total || state !== null} loading={!total || state === 'ACCEPTING'}>
        Pay now · ${total}
      </Button>
      <Button onPress={onDeclinePressed} subdued disabled={state !== null} loading={state === 'DECLINING'} >
        Decline this offer
      </Button>
    </BlockStack>
  );
}
Vanilla のコード
src/index.js
Vanillaのコード
import {extend, BlockStack, Button, CalloutBanner, Heading, Image, InlineStack, Text, TextContainer, Separator, Tiles, TextBlock, Layout} from '@shopify/post-purchase-ui-extensions';

extend('Checkout::PostPurchase::ShouldRender', async ({storage}) => {
  const postPurchaseOffer = await fetch('http://localhost:8077/get-offer').then((res) => res.json());
  await storage.update(postPurchaseOffer);

  return {render: true};
});

extend('Checkout::PostPurchase::Render', (root, {done, storage, calculateChangeset, applyChangeset, inputData}) => {
    const {variantId, productTitle, productImageURL, productDescription, discountedPrice, originalPrice} = storage.initialData;
    const changes = [{type: 'add_variant', variantId, quantity: 1}];

    const calloutBannerComponent = root.createComponent(
      CalloutBanner,
      {title: 'This is a demo'},
      [
        root.createComponent(Text, {size: 'medium'}, `Add the ${productTitle} to your order and `),
        root.createComponent(Text, {size: 'medium', emphasized: true}, 'save 15%.'),
      ]
    );

    const priceHeaderComponent = root.createComponent(
      InlineStack, {alignment: 'baseline', spacing: 'loose'}, [
        root.createComponent(TextContainer, {alignment: 'trailing', spacing: 'loose'}, [
            root.createComponent(Text, {role: 'deletion', size: 'large'}, originalPrice),
            root.createComponent(Text, {emphasized: true, size: 'large'}, `${discountedPrice} CAD`),
          ]
        ),
      ]
    );

    const productDescriptionTextBlocks = productDescription.map((text, index) =>
      root.createComponent(TextBlock, {key: index, subdued: true}, text)
    );
    const productDescriptionComponent = root.createComponent(BlockStack, {spacing: 'xtight'}, productDescriptionTextBlocks);

    const acceptButton = root.createComponent(Button, {onPress: acceptOffer, submit: true, disabled: true, loading: true});
    const declineButton = root.createComponent(Button, {onPress: declineOffer, subdued: true}, 'Decline this offer');
    const buttonsComponent = root.createComponent(BlockStack, {}, [acceptButton, declineButton]);

    const wrapperComponent = root.createComponent(BlockStack, {}, [
      root.createComponent(Heading, {}, productTitle),
      priceHeaderComponent,
      productDescriptionComponent,
      buttonsComponent,
    ]);


    // 配送料と税金を取得し、UIを更新
    (async function updatePriceBreakdownUI() {
      // shopifyにアップセルのための配送料と税金の計算を依頼します。
      const result = await calculateChangeset({changes});

      // レスポンスから値を抽出
      const shipping = result.calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
      const taxes = result.calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;
      const total = `${Number(discountedPrice) + (Number(shipping) || 0) + (Number(taxes) || 0)}`;

      // さて、送料と税金がわかったところで、価格内訳のUIコンポーネントを作成してみましょう ...
      function createPriceBreakdownLine({label, amount, textSize = 'small'}) {
        return root.createComponent(Tiles, {}, [
          root.createComponent(TextBlock, {size: 'small'}, label),
          root.createComponent(TextContainer, {alignment: 'trailing'},
            root.createComponent(TextBlock, {emphasized: true, size: textSize}, amount)
          ),
        ]);
      }

      const priceBreakdownComponent = root.createComponent(
        BlockStack,
        {spacing: 'tight'},
        [
          root.createComponent(Separator),
          createPriceBreakdownLine({root, label: 'Subtotal', amount: discountedPrice}),
          createPriceBreakdownLine({root, label: 'Shipping', amount: shipping ?? 'Free'}),
          createPriceBreakdownLine({root, label: 'Taxes', amount: taxes ?? 'Free'}),
          root.createComponent(Separator),
          createPriceBreakdownLine({root, label: 'Total', amount: `$${total}`, textSize: 'medium'}),
        ]
      );

      // そして、UIにコンポーネントを追加します。
      wrapperComponent.insertChildBefore(priceBreakdownComponent, buttonsComponent);

      // また、受け入れボタンを有効にすることも忘れないでください。
      acceptButton.updateProps({disabled: false, loading: false});
      acceptButton.appendChild(`Pay now · ${total}`)
    })();

    // 辞退ボタンのクリックハンドラ
    function declineOffer() {
      acceptButton.updateProps({disabled: true});
      declineButton.updateProps({disabled: true, loading: true});
      done();
    }

    // アクセプトボタンのクリックハンドラ
    function acceptOffer() {
      async function doAcceptOrder() {
        // チェンジセットに署名するために、アプリサーバにリクエストを行います。
        const jwtToken = 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);

        // チェンジセットを適用するためにShopifyのサーバーにリクエストします。
        await applyChangeset(jwtToken);
        done();
      }

      // ボタンの状態を更新してから、非同期関数を行います。
      acceptButton.updateProps({disabled: true, loading: true});
      declineButton.updateProps({disabled: true});
      doAcceptOrder();
    }

    // すべてのコンポーネントをまとめ、UIをレンダリングします。
    root.appendChild(
      root.createComponent(BlockStack, {spacing: 'loose'}, [
        calloutBannerComponent,
        root.createComponent(
          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]},
            ],
          },
          [
            root.createComponent(Image, {source: productImageURL, description: 'Product photo'}),
            root.createComponent(BlockStack), wrapperComponent,
          ]
        ),
      ])
    );
  }
);

コードを更新すると、購入後のページは以下のようにレンダリングされます。

Pay Now ボタンをクリックすると、注文内容が更新されたサンキューページが表示され、お客様に通知メールが送信されます。

拡張機能コードの例の動作

以下のセクションでは、ステップ 3 の拡張コード例の各部分がどのように動作するかを説明します。

Checkout::PostPurchase::ShouldRenderでの商品データの取得

Checkout::PostPurchase::ShouldRenderハンドラは、ローカルで動作しているサーバから商品データを事前に取得します。

src/index.js
extend('Checkout::PostPurchase::ShouldRender', async ({storage}) => {
  const postPurchaseOffer = await fetch('http://localhost:8077/get-offer').then(res => res.json());

calculateChangesetを使って送料や税金を計算する

配送料と税金は、配送先住所に基づいて計算されます。これは、API で提供されているcalculateChangeset関数を使用して、チェックアウト時に行われます。

src/index.jsx
Reactのコード
const {done, storage, calculateChangeset, applyChangeset, inputData} = useExtensionInput();
Vanilla のコード
src/index.jsx
Vanillaのコード
extend('Checkout::PostPurchase::Render', (root, {done, storage, calculateChangeset, applyChangeset, inputData}) => {

コンポーネントがレンダリングされると、コードは非同期関数calculateChangesetを呼び出し、その結果を待ち、価格の内訳を UI に更新します。

src/index.jsx
Reactのコード
const changes = [{type: 'add_variant', variantId, quantity: 1}];
// ...

async function updatePriceBreakdown(){
  const result = await calculateChangeset({changes});

  // レスポンスから値を抽出します。
  const shipping = result.calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
  const taxes = result.calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;

  setShipping(shipping);
  setTaxes(taxes);
  setTotal(`${Number(discountedPrice) + (Number(shipping) || 0) + (Number(taxes) || 0)}`);
}
Vanilla のコード
src/index.jsx
Vanillaのコード
const changes = [{type: 'add_variant', variantId, quantity: 1}];
// ...

(async function updatePriceBreakdownUI() {
  // shopifyにアップセルのための配送料と税金の計算を依頼します。
  const result = await calculateChangeset({changes});

  // レスポンスから値を抽出します。
  const shipping = result.calculatedPurchase?.addedShippingLines[0]?.priceSet?.presentmentMoney?.amount;
  const taxes = result.calculatedPurchase?.addedTaxLines[0]?.priceSet?.presentmentMoney?.amount;
  const total = `${Number(discountedPrice) + (Number(shipping) || 0) + (Number(taxes) || 0)}`;

  // 送料と税金がわかったところで、価格内訳のUIコンポーネントを作成してみましょう ...
  function createPriceBreakdownLine({label, amount, textSize = 'small'}) {
    return root.createComponent(Tiles, {}, [
      root.createComponent(TextBlock, {size: 'small'}, label),
      root.createComponent(TextContainer, {alignment: 'trailing'},
        root.createComponent(TextBlock, {emphasized: true, size: textSize}, amount)
      ),
    ]);
  }

  const priceBreakdownComponent = root.createComponent(
    BlockStack,
    {spacing: 'tight'},
    [
      root.createComponent(Separator),
      createPriceBreakdownLine({root, label: 'Subtotal', amount: discountedPrice}),
      createPriceBreakdownLine({root, label: 'Shipping', amount: shipping ?? 'Free'}),
      createPriceBreakdownLine({root, label: 'Taxes', amount: taxes ?? 'Free'}),
      root.createComponent(Separator),
      createPriceBreakdownLine({root, label: 'Total', amount: `$${total}`, textSize: 'medium'}),
    ]
  );

  // UIにコンポーネントを追加します。
  wrapperComponent.insertChildBefore(priceBreakdownComponent, buttonsComponent);

  // 受け入れボタンを有効にします。
  acceptButton.updateProps({disabled: false, loading: false});
  acceptButton.appendChild(`Pay now · ${total}`)
})();

applyChangesetを使って注文を更新する

acceptOffer ハンドラは、changes内容を JWT トークンとして署名するために、サーバにリクエストを送信します。リクエストには inputData.token が含まれており、サーバーはこれを使って Shop のリクエストを検証します。次のコードは、サーバーから返された JWT トークンでapplyChangesetを呼び出します。

src/index.jsx
Reactのコード
function acceptOffer(){
  async function doAcceptOrder(){
    // ...

    await applyChangeset(jwtToken);
    done();
  }

  // UIを更新するためにステートを設定し、非同期関数を呼び出します。
  setState('ACCEPTING');
  doAcceptOrder();
}
Vanilla のコード
src/index.js
Vanillaのコード
function acceptOffer() {
  async function doAcceptOrder() {
    // ...

    await applyChangeset(jwtToken);
    done();
  }

  // まず、ボタンの状態を更新してから、非同期関数を呼び出します。
  acceptButton.updateProps({disabled: true, loading: true});
  declineButton.updateProps({disabled: true});
  doAcceptOrder();
}

次のステップ

Shopify アプリのご紹介

Shopify アプリである、「商品ページ発売予告アプリ | リテリア Coming Soon」は、商品ページを買えない状態のまま、発売日時の予告をすることができるアプリです。Shopify で Coming Soon 機能を実現することができます。

https://apps.shopify.com/shopify-application-314?locale=ja&from=daniel

Shopify アプリである、「らくらく日本語フォント設定|リテリア Font Picker」は、ノーコードで日本語フォントを使用できるアプリです。日本語フォントを導入することでブランドを演出することができます。

https://apps.shopify.com/font-picker-1?locale=ja&from=daniel

Discussion

ログインするとコメントできます