BlogRight arrowTutorialRight ArrowReact Nativeアプリ内購入: シンプルな実装。チュートリアル
BlogRight arrowTutorialRight ArrowReact Nativeアプリ内購入: シンプルな実装。チュートリアル

React Nativeアプリ内購入: シンプルな実装。チュートリアル

React Nativeアプリ内購入: シンプルな実装。チュートリアル
Listen to the episode
React Nativeアプリ内購入: シンプルな実装。チュートリアル

クロスプラットフォームのアプリ開発フレームワークは、開発者の負担を確実に軽減して、一度に複数のプラットフォーム用のアプリを構築できるようにします。ただし、いくつかの欠点があります。たとえば、React Nativeには、アプリ内購入 (in-app purchase) を実装するための既成のツールがありません。そのため、必然的にサードパーティのライブラリに着目する必要があります。

アプリ内購入の実装にはどのようなオプションがありますか?

React Nativeアプリのアプリ内サブスクリプション (in-app subscription) で人気のあるライブラリは、react-native-iapとexpo-in-app-purchasesです。ただし、他のライブラリと比較して、react-native-adaptyには多数のメリットがあるため、このライブラリについて説明します。

● 他のライブラリとは異なり、サーバーベース (server-side) の購入検証 (purchase validation) に対応しています。

プロモーションオファー (promo offer) から前払い (pay upfront) 機能まで、App Storeで最近実装されたすべての機能をサポートしています。また、今後の新機能への対応も迅速です。

● より明確でシンプルなコードになります。

● 完全なリリースサイクルを通過しなくても、提供するプロダクトを変更したり、新しいオファーを追加または削除したりできます。ベータ版をリリースして承認を待つ必要はありません。

その他にもAdapty SDKには、充実した機能が揃っています。すべての主要な指標、コホート分析 (Cohort analysis)、サーバーベースの購入検証、ペイウォール (paywall) のA/Bテスト (A/B test) 、柔軟なセグメンテーションによるプロモーションキャンペーン、サードパーティの分析ツールの統合など、組み込みの分析ツールを利用できます。

記事の内容

ここでは、React Nativeアプリでのアプリ内購入の設定について説明します。今回の内容は次のとおりです。

1. React Nativeアプリのアプリ内購入でExpoを使用できない理由

2. デベロッパーアカウントの作成

3. Adaptyの設定:

App Storeの設定

Playストアの設定

4. サブスクリプションの追加

5. ペイウォールの作成

6. React Native-adaptyのインストール

7. サンプルアプリと結果

このガイドでは、サブスクリプションユーザーに猫の写真を表示して、その他のユーザーにサブスクリプションのオファーを提示するアプリの構築を試みます。

⭐️ Download our guide on in-app techniques which will make in-app purchases in your app perfect

React Native アプリのアプリ内購入でExpoを使用できない理由

簡単に言うと、Expoの「マネージド」は、App Storeが提供する購入処理用のネイティブメソッド (別名StoreKit) をサポートしていません。引き続きReact Nativeを使用するか、Expo Bare Workflowを使用する必要があります。

Expoの使用を検討していた方はがっかりするかもしれませんが、使うことはできません。 Expoは、アプリ開発を効率化するReact Nativeフレームワークです。ただし、マネージドワークフローは、購入やサブスクリプションの処理と互換性がありません。Expoでは、StoreKitに必要なメソッドとコンポーネント (両方ともJavaScriptのみ) でネイティブコードを使用しません。モバイルストアでJavaScriptを使用してアプリ内購入を実装する方法はないため、「イジェクト」する必要があります。

デベロッパーアカウントの作成

まず、App Storeアカウントを作成して、iOSとAndroid の両方で購入とサブスクリプションを作成して設定する必要があります。20分以内で完了できるはずです。

App Store ConnectやGoogle Play Consoleでデベロッパーアカウントとプロダクトをまだ設定していない場合は、次のガイドを参照してください。

iOSの場合:ガイドの最初から「SKProductのリストの取得」の見出しまでお読みください。その後、ネイティブ実装について説明します。

Androidの場合:ガイドの最初から「アプリ内のプロダクトリストの取得」の見出しまでお読みください。

Adaptyの設定

react-native-adaptyの場合、最初にAdaptyダッシュボードを設定する必要があります。それほど時間はかかりませんが、Adaptyがハードコーディングよりも優れているという上記のすべてのメリットを得られます。

3番目の手順では、App StoreとGoogle Playの設定を求められます。

iOSの場合、次のことを行う必要があります。

● バンドルIDを指定する

● App Storeサーバ通知を設定する

● App Store Connect共有シークレットを指定する

これらのフィールドは、購入を完了するために必要です。

各フィールドには、手順ごとの方法を含む「説明」のヒントがあります。不明な点がある場合は、これらのヒントを確認してください。

バンドルIDは、アプリの一意のIDです。 Xcodeの [Targets] > [App Name] > [General] で指定したIDと一致する必要があります。

Androidの場合、必須フィールドは「パッケージ名」と「サービスアカウントキーファイル」です。これらすべてのフィールドには、説明に関するヒントがあります。Androidでのパッケージ名は、iOSでのバンドルIDに対応しています。コードで指定したものと一致する必要があります。これは、android.defaultConfig.applicationIdの/android/app/build.gradleファイルで確認できます。

4番目の手順では、Adapty SDKをアプリに連携するよう求められます。ただし、後で説明するため、今はこの手順を飛ばしてください。

新規登録したら、[設定] タブを確認してください。ここにパブリックSDKキーが表示されているため、覚えておきましょう。キーは後で必要になります。

サブスクリプションの追加

Adaptyでは、さまざまなサブスクリプションのプロダクトを使用します。猫の写真のサブスクリプションプランは、毎週、半年ごと、または毎年のいずれかです。これらのオプションは、それぞれ個別のAdaptyプロダクトになります。

ダッシュボードで、プロダクトが1つあることを指定しましょう。[Products & A/B Tests] → [Products] をクリックし、[Create product] をクリックします。

ここでは、プロダクト名、つまりサブスクリプションがAdaptyダッシュボードでどのように表示されるかを指定する必要があります。

App StoreプロダクトID と Play StoreアイテムID も指定する必要があります。必要に応じて、期間と分析用の名前も指定します。[Save] をクリックします。

ペイウォールの作成

ここでは、ユーザーの有料機能へのアクセス権を制限して、サブスクリプションのオファーを表示する画面として、ペイウォールを設計する必要があります。作成したプロダクトをペイウォールに追加する必要があります。これを行うには、同じセクション ([Products & A/B Tests] → [Paywalls]) で [Create paywall] をクリックします。

● 名前を見ただけで、どのペイウォールであるかを簡単に推測できるようなペイウォール名を選択してください。

● ペイウォールIDを使用して、このペイウォールをアプリに表示します。サンプルアプリでは、「cats_paywall」を使用します。

● [Product] ドロップダウンで、サブスクリプションを選択します。

[Save & publish] をクリックします。

設定は以上です。次に、依存関係を追加してコードを記述します。

react-native-adaptyのインストール

1. まず、依存関係を追加します。

yarn add react-native-adapty

2. iOSポッドをインストールします。CLIポッドをお持ちでない場合は、ダウンロードすることを強くお勧めします。iOS開発では、確かに多くのポッドが必要になります。

#pods get installed into the native iOS project, which, by default, is the /ios folderpod install --project-directory=ios

3. iOS React NativeプロジェクトはObj-Cで記述されているため、Obj-CがSwiftライブラリを読み取れるように、Swift Bridging Headerを作成する必要があります。そのためには、Xcodeプロジェクトを開いて新しいSwiftファイルを作成するだけです。Xcodeでは、Swift Bridging Headerを作成するかどうかを尋ねられるため、[Create] をクリックします。

4. Android の場合、プロジェクト (デフォルトでは「/android/build.gradle」) がバージョン1.4.0以降のkotlin-gradle-pluginを使用していることを確認します。


... buildscript { 
  ... dependencies { 
    ... classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0" 
  } 
} ...

5. Androidの場合、アプリの設定ファイル (デフォルトでは「/android/app/build.gradle」) にあるmultiDexを有効にする必要があります。


... 
android { 
  ... defaultConfig { 
    ... multiDexEnabled true 
  } 
}

これで準備が完了したので、コーディングを開始できるようになりました🎉

アプリでのプロダクトリストの取得

react-native-adaptyには、さまざまな便利な機能があります。遅かれ早かれ必ず必要になるため、フローの最初でライブラリを初期化する必要があります。アプリのコーディング画面で上にスクロールして (App.tsxでも同様に実行できます)、初期化を開始しましょう。


// import the method
import { activateAdapty } from 'react-native-adapty';

// We’ve had this App component in our app’s root
const App: React.FC = () => {
  ...
// we’re invoking it once in a root component on mount
  useEffect(() => {
    activateAdapty({ sdkKey: 'MY_PUBLIC_KEY' });
  },[]);
  ...
}

ここでは、MY_PUBLIC_KEYをダッシュ​​ボード設定にあるパブリックSDKキーに置き換えます。実際には、 activateAdapty()メソッドは複数回、複数の場所で呼び出すことができますが、引き続きこの方法を使用してください。

これで、Adapty ダッシュボードに追加したプロダクトを取得できます。


import { adapty } from 'react-native-adapty';

async function getProducts() {
	const {paywalls, products} = await adapty.paywalls.getPaywalls();

	return products;
}

それでは、実践してみましょう。ペイウォールからプロダクトを閲覧して購入できる軽量アプリを作成してみます。

Start for free

Convenient in-app purchases infrastructure.

Adapty SDK has it all:
— server-side purchase validation,
— all side cases covered,
— simple implementation.

Start for free

サンプルアプリ

基本ロジックが複雑になりすぎないように、ここから先は簡潔にします。また、TypeScriptでコーディングして、どのタイプがどこで使用されているかを示します。テストには、懐かしいiPhone 8を使用します。iOS 14以降、App StoreではエミュレーターでStoreKitを使用することが禁止されるようになりました。実機を使ってのみテストできます。

App.tsxルートコンポーネント

1. まず、ペイウォール表示ボタンを含むApp.tsxルートコンポーネントを作成しましょう。すでにreact-native-navigationからナビゲーションを設定しています。公式ドキュメントで推奨されているreact-navigationオプションよりもはるかに優れていると考えられます。

問題


import React, { useEffect, useState } from "react";
import { Button, StyleSheet, View } from "react-native";
import { adapty, activateAdapty, AdaptyPaywall } from "react-native-adapty";

export const App: React.FC = () => {
  const [paywalls, setPaywalls] = useState([]);

  useEffect(() => {
    async function fetchPaywalls(): Promise {
      await activateAdapty({ sdkKey: "MY_PUBLIC_KEY" });

      const result = await adapty.paywalls.getPaywalls();
      setPaywalls(result.paywalls);
    }

    fetchPaywalls();
  }, []);

  return (
    
      <Button
        title="Show the paywall"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("There is no such paywall");
          }
					// Switching to a paywall...
        }}
      />
    
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
});

何が起きているのでしょうか?マウント時に、fetchPaywalls()関数が呼び出されます。 SDK をアクティベーションして、ペイウォールをその状態で保存するため、ユーザーはボタンをタップした後に取得されるまで待つ必要がありません。ビューには、ダッシュボードで以前に設計したペイウォール画面に移動するボタンが1つのみ表示されます。

実際、ペイウォールを状態に保存せずに、ここで取得することは可能です。デフォルトでは、adapty.paywalls.getPaywalls()は (起動時にキャッシュした後) キャッシュストレージから取得します。つまり、メソッドがサーバーと通信するのを待つ必要はありません。

結果は次のとおりです。

react native in-app purchases tutorial payall

ペイウォールコンポーネント

2. 同じファイルにペイウォールコンポーネントを書きましょう。


// there are more imports here
import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";

// ...

interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise;
}
export const Paywall: React.FC = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    
      {paywall.products.map((product) => (
        
          {product.localizedTitle}
          <Button
            title={`Buy for за ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("Error occured :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        
      ))}
    
  );
};

// A new key
const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

ここでは、ペイウォールからプロダクトをマッピングし、各プロダクトの横に購入ボタンを表示します。

画面の登録

3. 表示を確認するために、この画面をreact-native-navigationに登録しましょう。他のナビゲーションを使用している場合は、この手順をスキップしてください。ルートindex.jsファイルは次のようになります。


import "react-native-gesture-handler";
import { Navigation } from "react-native-navigation";

import { App, Paywall } from "./App";

Navigation.registerComponent("Home", () => App);
Navigation.registerComponent("Paywall", () => Paywall);

Navigation.events().registerAppLaunchedListener(() => {
  Navigation.setRoot({
    root: { stack: { children: [{ component: { name: "Home" } }] } },
  });
});

「ペイウォールを表示する」ボタン

4. 次に、「ペイウォールを表示する」ボタンにアクションを割り当てる必要があります。この場合、[Navigation] を介してモーダルが表示されます。


Navigation.showModal({
    component: {
      name: "Paywall",
      passProps: {
        paywall,
        onRequestBuy: async (product) => {
          const purchase = await adapty.purchases.makePurchase(product);
          // Doing everything we need
          console.log("purchase", purchase);
        },
      },
    },
  });

App.tsxファイル全体:


import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";
export const App: React.FC = () => {
  const [paywalls, setPaywalls] = useState([]);
  useEffect(() => {
    async function fetchPaywalls(): Promise {
      await activateAdapty({
        sdkKey: "MY_PUBLIC_KEY",
      });

      const result = await adapty.paywalls.getPaywalls();
      setPaywalls(result.paywalls);
    }

    fetchPaywalls();
  }, []);

  return (
    
      <Button
        title="Show paywall"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("There is no such paywall");
          }

          Navigation.showModal({
            component: {
              name: "Paywall",
              passProps: {
                paywall,
                onRequestBuy: async (product) => {
                  const purchase = await adapty.purchases.makePurchase(product);
                  // Doing everything we need
                  console.log("purchase", purchase);
                },
              },
            },
          });
        }}
      />
    
  );
};

interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise;
}
export const Paywall: React.FC = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    
      {paywall.products.map((product) => (
        
          {product.localizedTitle}
          <Button
            title={`Buy for ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("Error occured :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        
      ))}
    
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

以上で、ペイウォールをユーザーに表示できるようになりました。

サンドボックスでiOSのサブスクリプションをテストする場合は、独自のサンドボックステスターアカウントを作成する必要があります。テストを効率化するために、サンドボックスサブスクリプションはすぐに無効になることに注意してください。Androidの場合、追加のアカウントは必要ありません。エミュレーターでテストを実行することもできます。

ユーザーにアクティブなサブスクリプションがあるかどうかを確認する

エンドユーザーに有料コンテンツへのアクセスを許可するために、アクティブなサブスクリプション データをどこに保存するかを指定しなければなりません。Adaptyでは、ユーザーに関連付けられたすべての購入情報が保存されるため、この場合にも役立ちます。仮にユーザーがサブスクリプションを利用していない場合は、ペイウォールボタンが表示されるとします。サブスクリプションを利用している場合は、ユーザーに猫の写真を表示します。.

アクティブなサブスクリプションデータはサーバーまたはキャッシュストレージから取得されるため、ローダーが必要になります。効率化のために、isLoadingとisPremiumの状態を追加しましょう。


// ...
export const App: React.FC = () => {
	const [isLoading, setIsLoading] = useState<boolean>(true);
	const [isPremium, setIsPremium] = useState<boolean>(false);
  const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]);

	useEffect(() => {
    async function fetchPaywalls(): Promise<void> {
      try {
        await activateAdapty({
          sdkKey: "MY_PUBLIC_KEY",
        });

        const profile = await adapty.purchases.getInfo();
        const isSubscribed = profile.accessLevels.premium.isActive;
        setIsPremium(isSubscribed);

        if (!isSubscribed) {
          const result = await adapty.paywalls.getPaywalls();
          setPaywalls(result.paywalls);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchPaywalls();
  }, []);

  // ...
}
// ...

変更点は次のとおりです。状態にフラグを追加しました。fetchPaywalls()の記述全体がtry-catchブロックにラップされるようになり、あらゆる想定されるシナリオでコードがsetIsLoading(false)に到達するようになりました。ユーザーにアクティブなサブスクリプションがあるかどうかを確認するために、ユーザーのプロファイル (すべてのサブスクリプションデータを含む) を取得し、profile.accessLevels.premium.isActiveの値を確認します。必要に応じてアクセスレベル (accessLevels) を使用できますが (基本的には「ゴールド」や「プレミアム」などのサブスクリプションレベルにすぎません)、ここでは既定値のままにします。Adaptyでは、有料機能へのアクセスレベルを自動的に作成します。ほとんどのアプリでは、これで十分です。isActiveは、このアクセスレベルのアクティブなサブスクリプションがある間は「true」のままになります

ここからは、すべてが簡単に思えるはずです。ユーザーにプレミアムレベルのサブスクリプションステータスがある場合、ペイウォールを取得する必要はありません。ローダーを無効にしてコンテンツを表示するだけです。


export const App: React.FC = () => {
// ...
const renderContent = (): React.ReactNode => {
  if (isLoading) {
    return <Text>Loading...</Text>;
  }

  if (isPremium) {
    return (
      <Image
        source={{
          url: "https://25.media.tumblr.com/tumblr_lugj06ZSgX1r4xjo2o1_500.gif",
          width: Dimensions.get("window").width * 0.8,
          height: Dimensions.get("window").height * 0.8,
        }}
      />
    );
  }

  return (
    <Button
      title="Show paywall"
      onPress={() => {
        const paywall = paywalls.find(
          (paywall) => paywall.developerId === "cats_paywall"
        );

        if (!paywall) {
          return alert("There is no such paywall");
        }

        Navigation.showModal<PaywallProps>({
          component: {
            name: "Paywall",
            passProps: {
	            paywall,
		          onRequestBuy: async (product) => {
		            const purchase = await adapty.purchases.makePurchase(product);
	              const isSubscribed =
		              purchase.purchaserInfo.accessLevels?.premium.isActive;
                setIsPremium(isSubscribed);
                Navigation.dismissAllModals();
              },
            },
          },
        });
      }}
    />
  );
};

return <View style={styles.container}>{renderContent()}</View>;
};

ここでは、コンテンツをレンダリングする関数といくつかのロジックをonRequestBuyに追加しています。つまり、isPremiumの状態を更新して、モーダルを終了します。

最終結果は次のとおりです。

ファイル全体:


import React, { useEffect, useState } from "react";
import {
  Button,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
  PlatformColor,
  Image,
  Dimensions,
} from "react-native";
import {
  adapty,
  activateAdapty,
  AdaptyPaywall,
  AdaptyProduct,
} from "react-native-adapty";
import { Navigation } from "react-native-navigation";

export const App: React.FC = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [isPremium, setIsPremium] = useState(false);
  const [paywalls, setPaywalls] = useState([]);

  useEffect(() => {
    async function fetchPaywalls(): Promise {
      try {
        await activateAdapty({
          sdkKey: "MY_PUBLIC_KEY",
        });

        const profile = await adapty.purchases.getInfo();
        const isSubscribed = profile.accessLevels.premium.isActive;
        setIsPremium(isSubscribed);

        if (!isSubscribed) {
          const result = await adapty.paywalls.getPaywalls();
          setPaywalls(result.paywalls);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchPaywalls();
  }, []);

  const renderContent = (): React.ReactNode => {
    if (isLoading) {
      return Loading...;
    }

    if (isPremium) {
      return (
        
      );
    }

    return (
      <Button
        title="Show a paywall"
        onPress={() => {
          const paywall = paywalls.find(
            (paywall) => paywall.developerId === "cats_paywall"
          );

          if (!paywall) {
            return alert("There is no such a paywall");
          }

          Navigation.showModal({
            component: {
              name: "Paywall",
              passProps: {
                paywall,
                onRequestBuy: async (product) => {
                  const purchase = await adapty.purchases.makePurchase(product);
                  const isSubscribed =
                    purchase.purchaserInfo.accessLevels?.premium.isActive;
                  setIsPremium(isSubscribed);
                  Navigation.dismissAllModals();
                },
              },
            },
          });
        }}
      />
    );
  };

  return {renderContent()};
};
interface PaywallProps {
  paywall: AdaptyPaywall;
  onRequestBuy: (product: AdaptyProduct) => void | Promise;
}
export const Paywall: React.FC = ({ paywall, onRequestBuy }) => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    
      {paywall.products.map((product) => (
        
          {product.localizedTitle}
          <Button
            title={`Buy for ${product.localizedPrice}`}
            disabled={isLoading}
            onPress={async () => {
              try {
                setIsLoading(true);
                await onRequestBuy(product);
              } catch (error) {
                alert("An error occured :(");
              } finally {
                setIsLoading(false);
              }
            }}
          />
        
      ))}
    
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center" },
  paywallContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "space-evenly",
    backgroundColor: PlatformColor("secondarySystemBackground"),
  },
});

まとめ

最終的に、優れたデザインで便利なサブスクリプションアプリを構築することができました。有料プランを購入したユーザーは猫の写真を閲覧でき、それ以外のユーザーには代わりにペイウォール画面が表示されます。このガイドでは、アプリにアプリ内購入を実装するために必要なすべての手順を説明しました。StoreKitについて詳細を学びたい方は、引き続きご注目ください。ありがとうございました。

Further reading

Adapty September Update: A/B Tests versions, Home, CSV Export
Adapty September Update: A/B Tests versions, Home, CSV Export
October 4, 2021
3 min read
First steps to better monetization
First steps to better monetization
February 15, 2022
38 min listen
iOS in-app purchases, part 4: server-side purchase validation
iOS in-app purchases, part 4: server-side purchase validation
August 26, 2021
15 min read