[シリーズ連載] Shopify Post-Purchase Checkout Extension
Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編② 〜複数商品〜
この機能は既に実用可能ではありますが、執筆時点の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;
}
- 01 Shopify Post-Purchase Extensionでレジ前販売を実装する 概要編 >
- 02 Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ① >
- 03 Shopify Post-Purchase Extensionでレジ前販売を実装する チュートリアル編 ② >
- 04 Shopify Post-Purchase Extensionでレジ前販売を実装する コンポーネント紹介編 ① 〜見た目・入力〜 >
- 05 Shopify Post-Purchase Extensionでレジ前販売を実装する コンポーネント紹介編 ② 〜レイアウト〜 >
- 06 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編① 〜ランダム商品〜 >
- 07 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編② 〜複数商品〜
- 08 Shopify Post-Purchase Extensionでレジ前販売を実装する 機能拡張編③ 〜ディスカウント・確認〜 >
- 09 Shopify Post-Purchase Extensionでレジ前販売を実装する 仕上げ編 〜モバイル〜 >