2023.02.13[Mon]
[シリーズ連載] Shopify Post-Purchase Checkout Extension

Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編② 〜複数商品〜

  • Shopify

目次

この機能は既に実用可能ではありますが、執筆時点の2023年2月現在も「ベータ版」という位置付けであり、今後仕様が変更になる可能性があります。また、公式ドキュメントも随時更新されており、一部の表現やコードの内容が一致しない可能性があります。

Webエンジニアの茄子です。
前回は、商品が固定ではなくランダムに入れ替わるようにしました。( https://techlab.q-co.jp/articles/81/ )
今回は、複数の商品が出るように変更します。

※ 本記事は変更が広範囲であるため、各節での解説は適宜抜粋して行います。記事の最後にファイル全体を載せます。
※ 本記事のコードでは商品に登録されている画像が正方形の想定で調整しています。別の比率の画像を使う場合、適宜調整してください。(レイアウト系コンポーネント解説 : https://techlab.q-co.jp/articles/79/

ShouldRenderフック

Post-Purchaseページを出すかどうかを決めるShouldRenderフック内で、前回PickedProductとしたところが配列で返るようにします。
乱数の配列が返ってくるようにしたいので、そのための関数を定義します。

function generateRandomNumbers(quantity, max) {
  const arr = [];
  while (arr.length < quantity) {
    var candidateInt = Math.floor(Math.random() * max) + 1;
    if (arr.indexOf(candidateInt) === -1) arr.push(candidateInt);
    if (arr.length === max) {
      console.warn(`All candidate numbers are already included.`);
      break;
    }
  }
  return arr;
}

ShouldRender の最後の部分を↓このように調整します。
これでinitialDataに配列で入ってくるようになります。

if (render) {
  let pickedProducts = candidateProducts;
  // 候補からランダムに
  if(candidateProducts.length > 2) {
    const randomNumbers = generateRandomNumbers(2, candidateProducts.length);
    pickedProducts = candidateProducts.filter((element, i) => {
      return randomNumbers.includes(i + 1); // 乱数 -1 が配列の添字
    })
  }

  // Saves initial state, provided to `Render` via `storage.initialData`
  await storage.update(pickedProducts);
}

コンポーネント切り出し

ここまで、UI部分はチュートリアルの状態をだいたいそのまま使っていて一続きになっているので、パーツごとに切り出します。
記事内では全体を掲載する都合上ファイル分割はしていませんが、実際は適宜やりやすいようにファイルを分けて頂いて構いません。

商品のタイトル・説明のまとまりを ProductInformation、値段のリストをPriceSummary、ボタンのセットをProceedButtonsとします。

function ProductInformation({ productTitle, discountedPrice, originalPrice, calculatedPurchase, productDescription }: {

  productTitle: string;
  discountedPrice: string;
  originalPrice: string;
  calculatedPurchase: any;
  productDescription: string[];
}) {

  return (
    <Fragment>
      <Heading>{productTitle}</Heading>
      <PriceHeader
        discountedPrice={discountedPrice}
        originalPrice={originalPrice}
        loading={!calculatedPurchase}
      />
      <ProductDescription textLines={productDescription} />
    </Fragment>
  )
}

function PriceSummary({ discountedPrice, calculatedPurchase, shipping, taxes, total }: {
  discountedPrice: string;
  calculatedPurchase: any;
  shipping: string;
  taxes: string;
  total: string;
}) {

  return (
    <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>
  )
}

function ProceedButtons({ variantId, total }: {
  variantId: number;
  total: string;
}) {
  const { loading, acceptOffer, declineOffer } = useContext(AppContext);
  const changes: AddVariantChange[] = [{ type: 'add_variant', variantId, quantity: 1 }];

  return (
    <BlockStack>
      <Button onPress={async () => { await acceptOffer(changes) }} submit loading={loading}>
        Pay now · {formatCurrency(total)}
      </Button>
      <Button onPress={declineOffer} subdued loading={loading}>
        Decline this offer
      </Button>
    </BlockStack>
  )
}

Product一個分を ProductListItem として切り出します。そのままだとテキストの長短によってレイアウトが崩れるので<Tiles>を入れています。

function ProductListItem
  ({ index, product, calculateChangeset }: {
    index: number;
    product: UpsellVariant;
    calculateChangeset: (changeset: any) => Promise<any>;
  }) {
  return (
    <>
      <InlineStack>
        <Tiles>
          <Image description="product photo" source={productImageURL} />
          <BlockStack>
            <ProductInformation
              productTitle={productTitle}
              discountedPrice={discountedPrice}
              originalPrice={originalPrice}
              calculatedPurchase={calculatedPurchase}
              productDescription={productDescription}
            />
            <PriceSummary
              discountedPrice={discountedPrice}
              calculatedPurchase={calculatedPurchase}
              shipping={shipping}
              taxes={taxes}
              total={total}
            />
            <ProceedButtons
              variantId={variantId}
              total={total}
            />
          </BlockStack>
        </Tiles>
      </InlineStack>
    </>
  )
}

loading 以外のstateや数値を準備するための下記の処理も、個別に必要なのでApp直下から ProductListItem内に移します。

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

...
......

const originalPrice: string = calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;

AppのReturnはこのようにします。少々幅が足りないのでlargeの一段目を768に変更してあります。

return (

...

    <Layout
      media={[
        { viewportSize: 'small', sizes: [1, 0, 1], maxInlineSize: 0.9 },
        { viewportSize: 'medium', sizes: [532, 0, 1], maxInlineSize: 420 },
        { viewportSize: 'large', sizes: [768, 38, 340] },
      ]}
    >
      <BlockStack>

        <ProductListItem />

      </BlockStack>
    </Layout>
  </BlockStack>
);

複数の商品を出す

App内returnの<ProductListItem /> 部分をmapで個数分出力するよう変更します。

{upsellProducts.map((_product, i) => {
  return (
    <ProductListItem
      key={i}
      product={_product}
      index={i}
      calculateChangeset={calculateChangeset}
    />
  )
})
}


購入・拒否ボタンを押した際にすべてのボタンがloadingになるようにContextで共有します。
ついでにacceptOffur, declineOffer関数もボタンのコンポーネントから呼ぶため、入れておきます。

type AppContext = {
  loading: boolean;
  setLoading: Dispatch<SetStateAction<boolean>>;
  acceptOffer: (changes: AddVariantChange[]) => Promise<void>;
  declineOffer: () => void;
}

const AppContext = React.createContext({} as AppContext);

Button側

function ProceedButtons({ variantId, total, index }: {
  variantId: number;
  total: string;
  index: number;
}) {
  const { loading, acceptOffer, declineOffer } = useContext(AppContext);
  const changes: AddVariantChange[] = [{ type: 'add_variant', variantId, quantity: 1 }];

...

acceptOfferは、changesを子コンポーネントから渡されるように変更します。

  async function acceptOffer(changes: AddVariantChange[]) {

以上の変更で、2つの商品が並ぶようになりました。
それぞれ、しっかり追加購入されることを確認してみてください。

今回はサンプルの状態をベースとして縦に並ぶようにしましたが、横並びにする場合は商品ごとのブロックを縦型に作り、<InlineStack>で並べるようにします。

コード全体

import React, { Children, Dispatch, Fragment, SetStateAction, useContext, useEffect, useState } from 'react';
import fetch from 'node-fetch';

import {
  extend,
  render,
  BlockStack,
  Button,
  CalloutBanner,
  Heading,
  Image,
  Layout,
  Text,
  TextBlock,
  TextContainer,
  Tiles,
  useExtensionInput,
  Separator,
  InlineStack,
} 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, inputData }): Promise<{ render: boolean }> => {
  console.log('Shouldrender hook');
  // 購入金額
  const initialPurchasePrice = parseInt(inputData.initialPurchase.totalPriceSet.presentmentMoney.amount, 10);
  // コレクション取得
  const postPurchaseOffer: UpsellVariant[] = await fetch(
    'http://localhost:8077/offer', {}
  ).then((res) => res.json());

  // 初期購入金額の半分以下の商品を候補にする
  const candidateProducts = postPurchaseOffer.filter((product) => {
    const originalPrice = parseInt(product.originalPrice, 10);
    return initialPurchasePrice / 2 > originalPrice;
  })

  //候補が無い場合はPost-Purchase画面を表示せずにThankyou画面へ
  const render = candidateProducts.length > 0;

  if (render) {
    let pickedProducts = candidateProducts;
    // 候補からランダムに
    if (candidateProducts.length > 2) {
      const randomNumbers = generateRandomNumbers(2, candidateProducts.length);
      pickedProducts = candidateProducts.filter((element, i) => {
        return randomNumbers.includes(i + 1); // 乱数 -1 が配列の添字
      })
    }

    // Saves initial state, provided to `Render` via `storage.initialData`
    await storage.update(pickedProducts);
  }

  return {
    render,
  };
});

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

type AppContext = {
  loading: boolean;
  setLoading: Dispatch<SetStateAction<boolean>>;
  acceptOffer: (changes: AddVariantChange[]) => Promise<void>;
  declineOffer: () => void;
}

const AppContext = React.createContext({} as AppContext);

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

  const upsellProducts = storage.initialData as UpsellVariant[];

  const [loading, setLoading] = useState(true);

  // 追加購入時の処理
  async function acceptOffer(changes: AddVariantChange[]) {
    setLoading(true);
    //  return 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 (
    <AppContext.Provider value={{ loading, setLoading, acceptOffer, declineOffer }} >
      <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: [768, 38, 340] },
          ]}
        >
          <BlockStack>
            {upsellProducts.map((_product, i) => {
              return (
                <ProductListItem
                  key={i}
                  product={_product}
                  index={i}
                  calculateChangeset={calculateChangeset}
                />
              )
            })
            }

          </BlockStack>
        </Layout>
      </BlockStack>
    </AppContext.Provider>
  );
}


function ProductListItem
  ({ index, product, calculateChangeset }: {
    index: number;
    product: UpsellVariant;
    calculateChangeset: (changeset: any) => Promise<any>;
  }) {
  const {
    variantId,
    productTitle,
    productImageURL,
    productDescription,
    originalPrice,
    discountedPrice
  } = product as UpsellVariant;

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

  const { loading, setLoading } = useContext(AppContext);

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

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

  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 || '';
  // 本来下記のコードでcalculateChangesetで出してきた値引き前価格が出ると思われるが、出ないのでAPIから来ている値を使う
  // const discountedPrice: string = calculatedPurchase?.updatedLineItems[0].totalPriceSet.presentmentMoney.amount || ''; //
  // const originalPrice: string = calculatedPurchase?.updatedLineItems[0].priceSet.presentmentMoney.amount;

  return (
    <>
      <InlineStack>
        <Tiles>
          <Image description="product photo" source={productImageURL} />
          <BlockStack>
            <ProductInformation
              productTitle={productTitle}
              discountedPrice={discountedPrice}
              originalPrice={originalPrice}
              calculatedPurchase={calculatedPurchase}
              productDescription={productDescription}
            />
            <PriceSummary
              discountedPrice={discountedPrice}
              calculatedPurchase={calculatedPurchase}
              shipping={shipping}
              taxes={taxes}
              total={total}
            />
            <ProceedButtons
              variantId={variantId}
              total={total}
            />
          </BlockStack>
        </Tiles>
      </InlineStack>
    </>
  )
}


function ProductInformation({ productTitle, discountedPrice, originalPrice, calculatedPurchase, productDescription }: {

  productTitle: string;
  discountedPrice: string;
  originalPrice: string;
  calculatedPurchase: any;
  productDescription: string[];
}) {

  return (
    <Fragment>
      <Heading>{productTitle}</Heading>
      <PriceHeader
        discountedPrice={discountedPrice}
        originalPrice={originalPrice}
        loading={!calculatedPurchase}
      />
      <ProductDescription textLines={productDescription} />
    </Fragment>
  )
}

function PriceSummary({ discountedPrice, calculatedPurchase, shipping, taxes, total }: {
  discountedPrice: string;
  calculatedPurchase: any;
  shipping: string;
  taxes: string;
  total: string;
}) {

  return (
    <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>
  )
}

function ProceedButtons({ variantId, total }: {
  variantId: number;
  total: string;
}) {
  const { loading, acceptOffer, declineOffer } = useContext(AppContext);
  const changes: AddVariantChange[] = [{ type: 'add_variant', variantId, quantity: 1 }];

  return (
    <BlockStack>
      <Button onPress={async () => { await acceptOffer(changes) }} submit loading={loading}>
        Pay now · {formatCurrency(total)}
      </Button>
      <Button onPress={declineOffer} subdued loading={loading}>
        Decline this offer
      </Button>
    </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) => {
        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}`;
}

function generateRandomNumbers(quantity: number, max: number): number[] {
  const arr: number[] = [];
  while (arr.length < quantity) {
    var candidateInt = Math.floor(Math.random() * max) + 1;
    if (arr.indexOf(candidateInt) === -1) arr.push(candidateInt);
    if (arr.length === max) {
      console.warn(`All candidate numbers are already included.`);
      break;
    }
  }
  return arr;
}

/** パッケージから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;
}


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でレジ前販売を実装する コンポーネント紹介編 ② 〜レイアウト〜 >
  6. 06 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編① 〜ランダム商品〜 >
  7. 07 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編② 〜複数商品〜
  8. 08 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編③ 〜ディスカウント・確認〜 >
  9. 09 Shopify Post-Purchase Extensionでレジ前販売を実装する 仕上げ編 〜モバイル〜 >
Strapi v4のAPIをカスタムして扱いやすくするShopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編① 〜ランダム商品〜