Collecting payments with React Native & RevenueCat (Part 4): Building a Paywall screen

Collecting payments with React Native & RevenueCat (Part 4): Building a Paywall screen

ยท

9 min read

๐Ÿ’ก

This is the third part of the "Collecting payments" series. Before proceeding, make sure to read Part 1, Part 2 & Part 3
We've finally reached the last part of the series! With all the tedious aspects taken care of, we just need to connect everything within the React Native app.

We'll add a React Provider wrapping the entire app, granting us easy access to various actions and information, such as purchasing a subscription, obtaining details about our offerings, and more!


SpiroKit for SaaS

In case you want to save weeks of painful work & research on your next React Native app, checkout SpiroKit for SaaS. It's a starter template that comes with Purchases, Push Notifications, Authentication, Analytics, Error Reporting and more. It also includes a Notion template to guide you through the entire process. If you get a licence, you won't need to follow the rest of this guide ;)


Building a development client

Before we can begin working on the app, we need to run it locally to test our changes. Since react-native-purchases is a native package, we require a development build for our app. Execute the following command to obtain a dev-client with EAS.

# Replace platform based on your needs
eas build --platform [ios|android] --profile development

Once the development build is ready, you'll need to install it on an actual device, as the RevenueCat package cannot be tested on a simulator.

Adding a Purchases provider to your app

We could drop all the necessary logic to retrieve our offerings information directly into a component. However, I believe it would be even better to have a provider with all this logic encapsulated, wrapping the entire app, so you can easily access the information from any component. Let's begin by creating a few files:

mkdir context
touch context/PurchasesProvider.tsx
touch context/PurchasesContext.tsx

Copy & paste the following code:

// context/PurchasesContext.tsx
import { createContext } from "react";
import {
  CustomerInfo,
  MakePurchaseResult,
  PurchasesOffering,
  PurchasesPackage,
  PurchasesStoreTransaction,
} from "react-native-purchases";

export type PurchasesContextProps = {
  currentOffering: PurchasesOffering | null;
  purchasePackage: (
    packageToPurchase: PurchasesPackage
  ) => Promise<MakePurchaseResult>;
  customerInfo?: CustomerInfo;
  isSubscribed: boolean;
  getNonSubscriptionPurchase: (
    identifier: string
  ) => Promise<PurchasesStoreTransaction | null | undefined>;
};

const PurchasesContext = createContext<PurchasesContextProps | null>(null);

export default PurchasesContext;
// context/PurchasesProvider.tsx
import React, { useEffect, useState } from "react";
import PurchasesContext from "./PurchasesContext";
import Purchases, {
  CustomerInfo,
  LOG_LEVEL,
  PurchasesOffering,
  PurchasesPackage,
} from "react-native-purchases";
import { Platform } from "react-native";

type PurchasesProviderProps = {
  children: JSX.Element | JSX.Element[];
};

const androidApiKey = process.env.EXPO_PUBLIC_REVENUECAT_API_KEY_ANDROID!;
const iosApiKey = process.env.EXPO_PUBLIC_REVENUECAT_API_KEY_IOS!;

const PurchasesProvider: React.FC<PurchasesProviderProps> = ({ children }) => {
  const [initialized, setInitialized] = useState(false);
  const [offering, setOffering] = useState<PurchasesOffering | null>(null);
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [customerInfo, setCustomerInfo] = useState<CustomerInfo>();

  const init = async () => {
    Purchases.configure({
      apiKey: Platform.OS === "android" ? androidApiKey : iosApiKey,
    });

    if (__DEV__) {
      Purchases.setLogLevel(LOG_LEVEL.DEBUG);
    }

    await getOfferings();

    // Add a listener to update the customerInfo state when the customer info changes, like when a user subscribes
    Purchases.addCustomerInfoUpdateListener((customerInfo) => {
      setCustomerInfo(customerInfo);
    });

    setInitialized(true);
  };

  /**
   * Fetch the current offerings from RevenueCat
   */
  const getOfferings = async () => {
    const offerings = await Purchases.getOfferings();
    const currentOffering = offerings.current;
    setOffering(currentOffering);
  };

  /**
   *
   * @param purchasedPackage The package to purchase
   * @returns The result of the purchase
   */
  const purchasePackage = async (purchasedPackage: PurchasesPackage) => {
    const result = await Purchases.purchasePackage(purchasedPackage);
    return result;
  };

  /**
   * Fetch the customer info from RevenueCat
   */
  const getCustomerInfo = async () => {
    const customerInfo = await Purchases.getCustomerInfo();
    setCustomerInfo(customerInfo);
  };

  /**
   * Check if the user is subscribed to any offering
   * @returns True if the user is subscribed to any offering
   */
  const checkIfUserIsSubscribed = async () => {
    if (!initialized || !customerInfo) return;
    const isPro = customerInfo.activeSubscriptions.length > 0;
    setIsSubscribed(isPro);
  };

  /**
   * Get the non-subscription purchase with the given identifier
   * @param identifier The identifier of the product to fetch
   * @returns The non-subscription purchase with the given identifier
   */
  const getNonSubscriptionPurchase = async (identifier: string) => {
    if (!initialized || !customerInfo) return null;

    const item = customerInfo.nonSubscriptionTransactions.find(
      (t) => t.productIdentifier === identifier
    );

    return item;
  };

  useEffect(() => {
    init();
    getCustomerInfo();
  }, []);

  useEffect(() => {
    // Check if the user is subscribed to any offering after the customer info changes
    checkIfUserIsSubscribed();
  }, [initialized, customerInfo]);

  // If the Purchases SDK is not initialized, return null
  if (!initialized) {
    return null;
  }

  return (
    <PurchasesContext.Provider
      value={{
        currentOffering: offering,
        purchasePackage,
        customerInfo,
        isSubscribed,
        getNonSubscriptionPurchase,
      }}
    >
      {children}
    </PurchasesContext.Provider>
  );
};

export default PurchasesProvider;

Using a custom hook for better developer experience

We'll need a custom hook to easily access all the exported members of our provider. Let's start by creating a new file for this.

mkdir hooks
touch hooks/usePurchases.tsx

Update the usePurchases.tsx with the following code:

// hooks/usePurchases.tsx
import React from "react";
import PurchasesContext, {
  PurchasesContextProps,
} from "@/context/PurchasesContext";

/**
 * Custom hook for managing purchases with RevenueCat.
 * @returns An object containing the following properties:
 *  - currentOffering - The current offering
 *  - purchasePackage - Purchase a package
 *  - customerInfo - The customer info
 *  - isSubscribed - Flag that indicates if the user is subscribed to any offering
 *  - getNonSubscriptionPurchase - Get the non-subscription purchase by identifier
 */
export const usePurchases = () =>
  React.useContext(PurchasesContext as React.Context<PurchasesContextProps>);

Wrapping the app with the new provider

Go to app/_layout.tsx and update the file to wrap the app with the PurchasesProvider.

// app/_layout.tsx

...
+ import PurchasesProvider from "@/context/PurchasesProvider";

...

export default function RootLayout() {
  ...
  return <RootLayoutNav />;
}

function RootLayoutNav() {
  const colorScheme = useColorScheme();

  return (
+   <PurchasesProvider>
      <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
        <Stack>
          <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
          <Stack.Screen name="modal" options={{ presentation: "modal" }} />
+         <Stack.Screen
+           name="subscriptions-paywall"
+           options={{ headerShown: false }}
+         />
        </Stack>
      </ThemeProvider>
+   </PurchasesProvider>
  );
}

Adding a "Paywall" route to show our products

This is where the magic happens! We need to create a new route for our Paywall screen.

touch app/subscriptions-paywall.tsx

With the following code, we render a horizontal FlatList component that displays all available subscription options. Feel free to modify this to suit your needs.

Additionally, we use the offering metadata to enhance the information and display the number of months for each product. This is also optional, and you can remove it if desired.

//app/subscriptions-paywall.tsx

import { usePurchases } from "@/hooks/usePurchases";
import { router } from "expo-router";
import React, { useState } from "react";
import {
  Dimensions,
  Platform,
  View,
  Image,
  ScrollView,
  Text,
  Button,
  Pressable,
  FlatList,
} from "react-native";
import { PRODUCT_CATEGORY, PurchasesPackage } from "react-native-purchases";

const screenHeight = Dimensions.get("window").height;
const screenWidth = Dimensions.get("window").width;

const SubscriptionsPaywall = () => {
  const { currentOffering, purchasePackage } = usePurchases();

  // Filter out non-subscription products from RevenueCat
  const filteredPackages = currentOffering?.availablePackages.filter(
    (item) => item.product.productCategory === PRODUCT_CATEGORY.SUBSCRIPTION
  );

  const [selectedPackage, setSelectedPackage] =
    useState<PurchasesPackage | null>(filteredPackages?.[0] || null);

  const [isLoading, setIsLoading] = useState(false);

  const handleContinue = async () => {
    if (!selectedPackage) return;

    try {
      setIsLoading(true);
      await purchasePackage(selectedPackage);
      router.back();
    } catch (error) {
      console.log(error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <View style={{ gap: 16, flex: 1, backgroundColor: "white" }}>
      <Image
        source={{
          uri: "https://i.imgur.com/qUZLBVT.jpg",
        }}
        height={screenHeight * 0.3}
        alt="Background image showing food"
        resizeMode="cover"
      ></Image>

      <ScrollView contentContainerStyle={{ flexGrow: 1 }}>
        <View style={{ flex: 1, alignItems: "center", gap: 24, padding: 24 }}>
          <Text style={{ textAlign: "center" }}>
            {`Get access to our premium features`}
          </Text>
          <PackagesCarousel
            packages={filteredPackages}
            onSelectPackage={(packageToSelect) =>
              setSelectedPackage(packageToSelect)
            }
            selectedPackage={selectedPackage}
            metadata={currentOffering?.metadata as PackageMetadata}
          ></PackagesCarousel>
        </View>
        <View style={{ padding: 24, gap: 8, marginBottom: 24 }}>
          <Text style={{ textAlign: "center" }}>
            {`All Subscriptions include a 7-Day Free Trial`}
          </Text>
          <Text style={{ textAlign: "center" }}>
            {`You can cancel at any time before the 7 day trial ends, and you won't be changed any amount`}
          </Text>
          <Button
            disabled={isLoading}
            title={isLoading ? "Processing..." : "Try it free for 7 days"}
            onPress={handleContinue}
          />
        </View>
      </ScrollView>
    </View>
  );
};

type Subscription = {
  identifier: string;
  cycles: number;
  discount?: string;
};

type PackageMetadata = {
  subscriptions: {
    ios: Subscription[];
    android: Subscription[];
  };
};

type PackagesCarouselProps = {
  onSelectPackage: (packageToSelect: PurchasesPackage) => void;
  packages?: PurchasesPackage[];
  metadata: PackageMetadata;
  selectedPackage: PurchasesPackage | null;
};

const PackagesCarousel: React.FC<PackagesCarouselProps> = (props) => {
  const { packages, onSelectPackage, selectedPackage, metadata } = props;
  const subscriptions =
    metadata.subscriptions[Platform.OS === "ios" ? "ios" : "android"];

  console.log("subscriptions", subscriptions);

  const RenderItem = (item: PurchasesPackage) => {
    if (!selectedPackage) return null;
    const isSelected = selectedPackage.identifier === item.identifier;
    const packageMetadata = subscriptions.find(
      (s) => s.identifier === item.product.identifier
    );

    console.log("packageMetadata", packageMetadata);

    if (!packageMetadata) return null;
    const discount = packageMetadata.discount;
    const amountOfMonths = packageMetadata.cycles;

    return (
      <Pressable
        onPress={() => {
          onSelectPackage(item);
        }}
      >
        <View
          style={{
            marginRight: 16,
            borderColor: isSelected ? "red" : "black",
            borderWidth: 3,
            borderRadius: 6,
            overflow: "hidden",
            minWidth: screenWidth * 0.4,
          }}
        >
          {discount && (
            <Text
              style={{
                position: "absolute",
                width: "100%",
                color: "black",
                textAlign: "center",
                padding: 4,
                backgroundColor: "yellow",
              }}
            >
              {discount}
            </Text>
          )}
          <View
            style={{
              marginTop: 16,
              padding: 16,
              alignItems: "center",
            }}
          >
            <Text
              style={{
                fontSize: 70,
              }}
            >
              {amountOfMonths}
            </Text>
            <Text>{amountOfMonths > 1 ? "months" : "month"}</Text>
            <Text>{item.product.priceString}</Text>
          </View>
        </View>
      </Pressable>
    );
  };

  if (!packages) return null;

  return (
    <FlatList
      contentContainerStyle={{
        paddingBottom: 4,
        marginRight: -16,
        overflow: "hidden",
      }}
      horizontal={true}
      data={packages}
      pagingEnabled={false}
      keyExtractor={(item) => item.identifier}
      renderItem={({ item }) => <RenderItem {...item}></RenderItem>}
    ></FlatList>
  );
};

export default SubscriptionsPaywall;

Finally, we need a way to navigate to our Paywall screen. Update the app/(tabs)/index.tsx like this:

// app/(tabs)/index.tsx

import { Button, StyleSheet } from "react-native";

import EditScreenInfo from "@/components/EditScreenInfo";
import { Text, View } from "@/components/Themed";
+ import { router } from "expo-router";

export default function TabOneScreen() {
  return (
    <View style={styles.container}>
      ...
+     <Button
+       title="Navigate to Paywall"
+       onPress={() => {
+         router.push("/subscriptions-paywall");
+       }}
+     />
    </View>
  );
}

const styles = StyleSheet.create({
  ...
});

Ready to test?

Run the following command to start your app using the development client:

npx expo start --dev-client

If everything went as expected, you'll see a home screen with a button to navigate to the Paywall route you created before.

Select any of the subscription options and confirm to purchase the package.

What's next?

With this solid foundation, you can now start working on your app to check customer information and grant or deny access to specific features based on their subscription status.

You can also create a dedicated screen to offer other products, such as a one-time purchase to remove ads. Explore the PurchasesProvider; it's filled with helpful methods.


Final words

I know this has been a long series, but I hope you find it useful (please let me know in the comments). To be honest, I chose to share all this information because I spent countless hours trying to understand all the different configurations involved in this process.

Moreover, These kinds of difficult tasks are the main reason so many apps fail before being released to the stores. Now, focus on your idea instead of how to add Payments ๐Ÿ˜‰

Happy coding!

ย