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

Shopify Post-Purchase Extensionでレジ前販売を実装する 仕上げ編 〜モバイル〜

  • Shopify

目次

  • - 現状
  • - 改善案
  • - 修正
  • - 完成形
  • - コード
  • - 解説
  • - デスクトップの確認
  • - 終わりに

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

Webエンジニアの茄子です。

前回まで、より実践的な機能の追加を進めました。本シリーズでは今まで開発をデスクトップサイズで進めてきましたが、最後にモバイルデバイスでの見た目を調整していきます。(本来は逆ですが・・・)

現状

まず、現状を確認します。
開発ツールでモバイルビューにして、リロードすると確認できます。

特に気にしてこなかったので当然崩れています。
問題点は
・画像が縦に伸びてしまっている
・デスクトップと同じような2カラムにするには、多少はみ出すのを許容するとしても右側部分の要素が多すぎる。
・一部のテキストや余白が大きすぎる。

これらを改善していきます。

改善案

モバイル用はモバイル用に構成することも可能ですが、今回は今までのデスクトップの状態との整合性も考えて以下の変更をします。
・画像と説明の2カラム + 下部に購入ボタン
・金額の詳細表示を赤い割引表示のところにまとめる。

修正

完成形

先に完成形の見た目を示します。
こちらを目指して修正を加えます。

コード

広範囲に渡るため全体を載せます。

クリックして展開
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,
  Checkbox,
  View,
} 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');
  console.log(storage);
  console.log(inputData);
  console.log(navigator);
  // 購入金額
  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 = {
  isDesktop: boolean;
  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 isDesktop = !navigator.userAgent.match(/(iPhone|iPod|iPad|Android|webOS|BlackBerry|IEMobile|Opera Mini)/i);

  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={{ isDesktop, 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 { isDesktop, 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,
      discount: {
        value: 15,
        valueType: "percentage",
        title: "特別割引",
      }
    }
  ];

  // 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;

  const layoutSize = isDesktop ? [0.5, 0.5] : [0.33, 0.66, 1];

  const DescriptionBlock = () => (
    <Fragment>
      <View inlinePadding='base'>
        <BlockStack>
          <ProductInformation
            productTitle={productTitle}
            discountedPrice={discountedPrice}
            originalPrice={originalPrice}
            total={total}
            calculatedPurchase={calculatedPurchase}
            productDescription={productDescription}
          />
          {isDesktop &&
            <PriceSummary
              discountedPrice={discountedPrice}
              calculatedPurchase={calculatedPurchase}
              shipping={shipping}
              taxes={taxes}
              total={total}
            />
          }
        </BlockStack>
      </View>
      <View blockPadding='base' inlinePadding='base'>
        <ProceedButtons
          variantId={variantId}
          total={total}
        />
      </View>
    </Fragment>
  );

  return (
    <>
      <InlineStack>
        <Layout sizes={layoutSize}>
          <Layout sizes={[1]} >
            <Image description="product photo" source={productImageURL} />
          </Layout>
          {isDesktop ? (
            <View>
              <DescriptionBlock />
            </View>
          ) : (
            <DescriptionBlock />
          )}
        </Layout>
      </InlineStack>
    </>
  )
}


function ProductInformation({ productTitle, discountedPrice, originalPrice, calculatedPurchase, productDescription, total }: {
  productTitle: string;
  discountedPrice: string;
  originalPrice: string;
  total: string;
  calculatedPurchase: any;
  productDescription: string[];
}) {

  return (
    <Fragment>
      <Heading level={2}>{productTitle}</Heading>
      <PriceHeader
        discountedPrice={discountedPrice}
        originalPrice={originalPrice}
        total={total}
        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 }];

  const [isChceked, setConfirmation] = useState(false);

  return (
    <BlockStack>
      <BlockStack alignment='center'>
        <Checkbox onChange={() => setConfirmation((prev) => !prev)}>
          購入ボタンを押すと決済に商品が追加されます
        </Checkbox>
      </BlockStack>
      <Button onPress={async () => { await acceptOffer(changes) }} submit loading={loading} disabled={!isChceked}>
        Pay now · {formatCurrency(total)}
      </Button>
      <Button onPress={declineOffer} subdued loading={loading}>
        Decline this offer
      </Button>
    </BlockStack>
  )
}

function PriceHeader({ discountedPrice, originalPrice, total, loading }: {
  discountedPrice: string;
  originalPrice: string;
  total: string;
  loading: boolean;
}) {
  const { isDesktop } = useContext(AppContext);
  const isMoblie = !isDesktop;
  const containerSpacing = isDesktop ? 'loose' : 'tight';
  const textSize = isDesktop ? 'large' : 'small';

  return (
    <TextContainer alignment="leading" spacing={containerSpacing}>
      <Text role="deletion" size={textSize}>
        {!loading && formatCurrency(originalPrice)}
      </Text>
      <Text emphasized size={textSize} appearance="critical">
        {' '}
        {!loading && formatCurrency(discountedPrice)}
      </Text>
      {isMoblie &&
        <Text emphasized size={textSize}>
          {!loading && " ( " +
            formatCurrency(total)
            + " with tax )"
          }
        </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 {
  const { isDesktop } = useContext(AppContext);
  const textBlockSize = isDesktop ? 'medium' : 'small';

  return (
    <Tiles>
      <TextBlock size={textBlockSize} emphasized>
        {label}
      </TextBlock>
      <TextContainer alignment="trailing">
        <TextBlock emphasized size={textBlockSize}>
          {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 ExplicitDiscount = {
  value: number;
  valueType: "percentage" | "fixed_amount";
  title: string;
}

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;
}


解説

追加点・変更点を解説していきます。

isDesktop
  const isDesktop = !navigator.userAgent.match(/(iPhone|iPod|iPad|Android|webOS|BlackBerry|IEMobile|Opera Mini)/i);

...
...

  return (
    <AppContext.Provider value={{ isDesktop, loading, setLoading, acceptOffer, declineOffer }} >

理想的にはスクリーン幅で制御したいところですが、Post-Purchaseページ内ではwindowオブジェクトへのアクセスができなくなっています。navigator, location は WorkerNavigator, WorkerLocation, として windowとは別口で用意されていて取得が可能ですので、UserAgent文字列を判定に使います。
モバイルファーストということでMobileをデフォルトと考えてisDesktopとしています。

DescriptionBlock
function ProductListItem

...

  const DescriptionBlock = () => (
    <Fragment>
      <View inlinePadding='base'>

...

  const layoutSize = isDesktop ? [0.5, 0.5] : [0.33, 0.66, 1];

  return (
    <>
      <InlineStack>
        <Layout sizes={layoutSize}>
          <Layout sizes={[1]} >
            <Image description="product photo" source={productImageURL} />
          </Layout>
          {isDesktop ? (
            <View>
              <DescriptionBlock />
            </View>
          ) : (
            <DescriptionBlock />
          )}
        </Layout>
      </InlineStack>
    </>
  )
}

全体のレイアウトを layoutSize として切り替えます。
デスクトップ:2カラム ( = [0.5, 0.5] )
モバイル:2+1カラム ( = [0.33, 0.66, 1] )
とする都合上、デスクトップでは ProceedButtonsも含めた画像以外の部分がまとめて1ノードとしてLayoutの子ノードになるように<View>で括ります。

また、画像が伸びるのを防ぐために <Layout sizes={[1]} > でImageを括ります。sizes={[1]} は特に意味のない記述に見えますが、これによって縦幅が内的に定義されるようです。

PriceSummaryはデスクトップのみで出るようにしておきます。

total (税込価格)
function PriceHeader({ discountedPrice, originalPrice, total, loading }: {
  discountedPrice: string;
  originalPrice: string;
  total: string;
  loading: boolean;

...

  return (
    <TextContainer alignment="leading" spacing={containerSpacing}>
...

      {isMoblie &&
        <Text emphasized size={textSize}>
          {!loading && " ( " +
            formatCurrency(total)
            + " with tax )"
          }
        </Text>
      }
    </TextContainer>
  );
}

削った金額詳細の代わりに、税込価格をモバイルの時のみ PriceHeader に追加します。

余白調整
function PriceHeader({ discountedPrice, originalPrice, total, loading }: {
...

  const { isDesktop } = useContext(AppContext);
  const isMoblie = !isDesktop;
  const containerSpacing = isDesktop ? 'loose' : 'tight';
  const textSize = isDesktop ? 'large' : 'small';

  return (
    <TextContainer alignment="leading" spacing={containerSpacing}>
      <Text role="deletion" size={textSize}>

Layout同様、デバイスに合わせて余白を調整します。今回必要になったのはここだけでした。

デスクトップの確認

デスクトップサイズでも、見た目がおかしくなっていないかを改めて確認します。

終わりに

今回でPost-Purchase Checkout Extensionシリーズは終了になります。
開発の参考になりましたら幸いです。

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でレジ前販売を実装する 仕上げ編 〜モバイル〜
next/image でパフォーマンスが改善されなかった話Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編③ 〜ディスカウント・確認〜