diff --git a/content/docs/android/guides/advanced/using-device-tiers-on-android.mdx b/content/docs/android/guides/advanced/using-device-tiers-on-android.mdx index 52158945..24b15567 100644 --- a/content/docs/android/guides/advanced/using-device-tiers-on-android.mdx +++ b/content/docs/android/guides/advanced/using-device-tiers-on-android.mdx @@ -6,7 +6,7 @@ description: "Target users based on their device performance capabilities to opt The `deviceTier` variable allows you to create targeted audiences based on device performance capabilities. This helps optimize paywall experiences by showing resource-appropriate content to different device types. You can reference this in campaign filters, dynamic values, or in paywall text via the `device.deviceTier` variable. -Device tier targeting is available starting in Android SDK version `2.2.2`. Make sure you're using this version or later to access this feature. +Device tier targeting is available starting in Android SDK version `2.2.3`. Make sure you're using this version or later to access this feature. ## How device tier works @@ -19,6 +19,15 @@ Device tier classification is based on several hardware factors: This automatic classification helps you deliver paywalls that perform well across the full spectrum of Android devices. +### Matching device ranges + +When creating device tier filters, you can use `contains` or `equals` for narrower matching: + +- **`contains`** - Broader matching that includes partial matches. For example, `deviceTier contains high` matches both `high` and `ultra_high` devices. +- **`equals`** - Exact matching for precise targeting. For example, `deviceTier equals high` matches only `high` tier devices, not `ultra_high`. + +Use `contains` when you want to target a range of similar device capabilities, and `equals` when you need precise control over which specific tier to target. + ## Device tier values The `device.deviceTier` attribute returns one of these values: diff --git a/content/docs/android/quickstart/tracking-subscription-state.mdx b/content/docs/android/quickstart/tracking-subscription-state.mdx index 09a3013a..14b733c6 100644 --- a/content/docs/android/quickstart/tracking-subscription-state.mdx +++ b/content/docs/android/quickstart/tracking-subscription-state.mdx @@ -1,5 +1,247 @@ --- -title: Tracking Subscription State +title: "Tracking Subscription State" +description: "Here's how to view whether or not a user is on a paid plan in Android." --- -../../../shared/tracking-subscription-state.mdx \ No newline at end of file +Superwall tracks the subscription state of a user for you. However, there are times in your app where you need to know if a user is on a paid plan or not. For example, you might want to conditionally show certain UI elements or enable premium features based on their subscription status. + +## Using subscriptionStatus + +The easiest way to track subscription status in Android is by accessing the `subscriptionStatus` StateFlow: + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get current status + val status = Superwall.instance.subscriptionStatus.value + when (status) { + is SubscriptionStatus.Active -> { + Log.d("Superwall", "User has active entitlements: ${status.entitlements}") + showPremiumContent() + } + is SubscriptionStatus.Inactive -> { + Log.d("Superwall", "User is on free plan") + showFreeContent() + } + is SubscriptionStatus.Unknown -> { + Log.d("Superwall", "Subscription status unknown") + showLoadingState() + } + } + } +} +``` + +The `SubscriptionStatus` sealed class has three possible states: + +- `SubscriptionStatus.Unknown` - Status is not yet determined +- `SubscriptionStatus.Active(Set)` - User has active entitlements (set of entitlement identifiers) +- `SubscriptionStatus.Inactive` - User has no active entitlements + +## Observing subscription status changes + +You can observe real-time subscription status changes using Kotlin's StateFlow: + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + Superwall.instance.subscriptionStatus.collect { status -> + when (status) { + is SubscriptionStatus.Active -> { + Log.d("Superwall", "User upgraded to pro!") + updateUiForPremiumUser() + } + is SubscriptionStatus.Inactive -> { + Log.d("Superwall", "User is on free plan") + updateUiForFreeUser() + } + is SubscriptionStatus.Unknown -> { + Log.d("Superwall", "Loading subscription status...") + showLoadingState() + } + } + } + } + } +} +``` + +## Using with Jetpack Compose + +If you're using Jetpack Compose, you can observe subscription status reactively: + +```kotlin +@Composable +fun ContentScreen() { + val subscriptionStatus by Superwall.instance.subscriptionStatus + .collectAsState() + + Column { + when (subscriptionStatus) { + is SubscriptionStatus.Active -> { + val entitlements = (subscriptionStatus as SubscriptionStatus.Active).entitlements + Text("Premium user with: ${entitlements.joinToString()}") + PremiumContent() + } + is SubscriptionStatus.Inactive -> { + Text("Free user") + FreeContent() + } + is SubscriptionStatus.Unknown -> { + Text("Loading...") + LoadingIndicator() + } + } + } +} +``` + +## Checking for specific entitlements + +If your app has multiple subscription tiers (e.g., Bronze, Silver, Gold), you can check for specific entitlements: + +```kotlin +val status = Superwall.instance.subscriptionStatus.value +when (status) { + is SubscriptionStatus.Active -> { + if (status.entitlements.contains("gold")) { + // Show gold-tier features + showGoldFeatures() + } else if (status.entitlements.contains("silver")) { + // Show silver-tier features + showSilverFeatures() + } + } + else -> showFreeFeatures() +} +``` + +## Setting subscription status + +When using Superwall with a custom purchase controller or third-party billing service, you need to manually update the subscription status. Here's how to sync with RevenueCat: + +```kotlin +class RevenueCatPurchaseController : PurchaseController { + + override suspend fun purchase( + activity: Activity, + product: StoreProduct + ): PurchaseResult { + return try { + val result = Purchases.sharedInstance.purchase(activity, product.sku) + + // Update Superwall subscription status based on RevenueCat result + if (result.isSuccessful) { + val entitlements = result.customerInfo.entitlements.active.keys + Superwall.instance.setSubscriptionStatus( + SubscriptionStatus.Active(entitlements) + ) + PurchaseResult.Purchased + } else { + PurchaseResult.Failed(Exception("Purchase failed")) + } + } catch (e: Exception) { + PurchaseResult.Failed(e) + } + } + + override suspend fun restorePurchases(): RestorationResult { + return try { + val customerInfo = Purchases.sharedInstance.restorePurchases() + val activeEntitlements = customerInfo.entitlements.active.keys + + if (activeEntitlements.isNotEmpty()) { + Superwall.instance.setSubscriptionStatus( + SubscriptionStatus.Active(activeEntitlements) + ) + } else { + Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive) + } + + RestorationResult.Restored + } catch (e: Exception) { + RestorationResult.Failed(e) + } + } +} +``` + +You can also listen for subscription changes from your billing service: + +```kotlin +class SubscriptionManager { + + fun syncSubscriptionStatus() { + Purchases.sharedInstance.getCustomerInfoWith { customerInfo -> + val activeEntitlements = customerInfo.entitlements.active.keys + + if (activeEntitlements.isNotEmpty()) { + Superwall.instance.setSubscriptionStatus( + SubscriptionStatus.Active(activeEntitlements) + ) + } else { + Superwall.instance.setSubscriptionStatus(SubscriptionStatus.Inactive) + } + } + } +} +``` + +## Using SuperwallDelegate + +You can also listen for subscription status changes using the `SuperwallDelegate`: + +```kotlin +class MyApplication : Application() { + + override fun onCreate() { + super.onCreate() + + Superwall.configure( + applicationContext = this, + apiKey = "YOUR_API_KEY", + options = SuperwallOptions().apply { + delegate = object : SuperwallDelegate() { + override fun subscriptionStatusDidChange( + from: SubscriptionStatus, + to: SubscriptionStatus + ) { + when (to) { + is SubscriptionStatus.Active -> { + Log.d("Superwall", "User is now premium") + } + is SubscriptionStatus.Inactive -> { + Log.d("Superwall", "User is now free") + } + is SubscriptionStatus.Unknown -> { + Log.d("Superwall", "Status unknown") + } + } + } + } + } + ) + } +} +``` + +## Superwall checks subscription status for you + +Remember that the Superwall SDK uses its [audience filters](/campaigns-audience#matching-to-entitlements) for determining when to show paywalls. You generally don't need to wrap your calls to register placements with subscription status checks: + +```kotlin +// ❌ Unnecessary +if (Superwall.instance.subscriptionStatus.value !is SubscriptionStatus.Active) { + Superwall.instance.register("campaign_trigger") +} + +// ✅ Just register the placement +Superwall.instance.register("campaign_trigger") +``` + +In your [audience filters](/campaigns-audience#matching-to-entitlements), you can specify whether the subscription state should be considered, which keeps your codebase cleaner and puts the "Should this paywall show?" logic where it belongs—in the Superwall dashboard. diff --git a/content/docs/dashboard/dashboard-campaigns/campaigns-audience.mdx b/content/docs/dashboard/dashboard-campaigns/campaigns-audience.mdx index 6356a084..28f1bd28 100644 --- a/content/docs/dashboard/dashboard-campaigns/campaigns-audience.mdx +++ b/content/docs/dashboard/dashboard-campaigns/campaigns-audience.mdx @@ -68,6 +68,21 @@ You can combine rules together, too. In the following example, if we only wanted ![](/images/campaigns-audience-combo-condition.png) +#### Using user properties or placement parameters +You can reference [user attributes](/sdk/quickstart/setting-user-properties) and [placement parameters](/docs/using-placement-parameters) in campaign filters. For example, if you were to set `hasLoggedCoffee` on your user, you could use that in a filter. + +**Adding user properties** +1. **Click** on Add Filter, and then click the **+** icon: +![](/images/campaign-filter-add-user-prop.png) +2. Select **User** and name the property, then save it: +![](/images/campaign-filter-add-user-prop-named.png) +3. Now you can select **User** (or type the property name) and the new property is available for user in your filter. Here, it's at the bottom: +![](/images/campaign-filter-add-user-prop-filter.png) + +**Adding placement parameters**
+This works exactly the same as above, just choose "Placement" instead: +![](/images/campaign-filter-add-placement-param.png) + #### Using rule groups You can combine rules together in groups. For example, you can mix **AND** and **OR** operators in the same group. To create a rule group, **click** on **+ Add Group** in the filter editor. diff --git a/content/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables.mdx b/content/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables.mdx index 5c96fdac..d03f68b6 100644 --- a/content/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables.mdx +++ b/content/docs/dashboard/dashboard-creating-paywalls/paywall-editor-variables.mdx @@ -20,13 +20,11 @@ By default, Superwall has three different type of variables for use: While those will allow you to cover several cases, you can also add your own custom variables too. - + Variables dealing with any product period, such as `someProduct.period`, `someProduct.periodly`, - and other similar variables, do _not_ localize automatically. This is because different languages - use varying orders and sentence structures for such terms. If you need them localized, add them as - you would any other term and enter localized values for each. Learn more about localization - [here](/paywall-editor-localization). - + and other similar variables, can localize automatically now. Learn more [here](/paywall-editor-localization#localizing-period-lengths). + + ### Using Variables You primarily use variables in the **component editor** and with [dynamic values](/paywall-editor-dynamic-values). When using a variable in text, follow the Liquid syntax to reference one: `{{ theVariable }}`. For example, to reference a variable in some text, you would: diff --git a/content/docs/dashboard/dashboard-settings/overview-settings-audit-log.mdx b/content/docs/dashboard/dashboard-settings/overview-settings-audit-log.mdx deleted file mode 100644 index 7d470c47..00000000 --- a/content/docs/dashboard/dashboard-settings/overview-settings-audit-log.mdx +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: "Audit Log" ---- - -In the **Audit Log** section within **Settings**, you can view virtually any action taken by users within Superwall: - -![](/images/overview-settings-audit-log.png) - -This is useful to see all of the actions you, or others within your team, have made: - -![](/images/overview-settings-audit-log-list.png) - -To view details about any of the actions, simply click on the row to expand it: - -![](/images/overview-settings-audit-log-details.png) diff --git a/content/docs/dashboard/guides/using-superwall-for-onboarding-flows.mdx b/content/docs/dashboard/guides/using-superwall-for-onboarding-flows.mdx new file mode 100644 index 00000000..d4ec1059 --- /dev/null +++ b/content/docs/dashboard/guides/using-superwall-for-onboarding-flows.mdx @@ -0,0 +1,120 @@ +--- +title: "Using Superwall for Onboarding Flows" +description: "Due to the flexible nature of our paywalls and SDK, you can easily use Superwall for onboarding flows." +--- + +Superwall's flexible paywall system can be used for building engaging onboarding experiences. With multi-page paywalls, dynamic content, and powerful targeting rules, you can create interactive onboarding flows without shipping app updates. Here's a quick guide on how to get up and running. + +## Creating an onboarding campaign + +Start by creating a [campaign](/campaigns) specifically for onboarding: + +1. Navigate to **Campaigns** in the dashboard sidebar +2. Click **+ New Campaign** +3. Name it something like "User Onboarding" +4. Add a [placement](/campaigns-placements) to trigger your onboarding flow + +## Triggering onboarding automatically + +There are two main approaches to triggering onboarding: + +### Using the `app_install` placement + +The [`app_install`](/campaigns-standard-placements) standard placement fires automatically when a user first installs your app. This is ideal for showing onboarding since it only fires once: + +1. Add `app_install` as a [placement](/campaigns-placements) to your onboarding campaign +2. Optionally, in your [audience filters](/dashboard/dashboard-campaigns/campaigns-audience#using-user-properties-or-placement-parameters), add a condition like `user.totalPaywallViews` equals `0` to ensure it only shows to brand new users + +Since `app_install` only fires once per install, you don't need additional logic to prevent it from showing multiple times. However, if users complete onboarding and you want to track that for other purposes, you can still [set a user attribute](/sdk/quickstart/setting-user-properties): + +```swift iOS +// Swift +Superwall.shared.setUserAttributes(["hasCompletedOnboarding": true]) + +// Android +Superwall.instance.setUserAttributes(mapOf("hasCompletedOnboarding" to true)) + +// Flutter +Superwall.instance.setUserAttributes({ + "hasCompletedOnboarding": true +}); + +// React Native / Expo +await Superwall.shared.setUserAttributes({ hasCompletedOnboarding: true }); +``` + +### Using a custom placement + +For more control over when onboarding appears, create a custom placement and register it manually: + +```swift iOS +// Swift +Superwall.shared.register(event: "start_onboarding") + +// Android +Superwall.instance.register("start_onboarding") + +// Flutter +Superwall.instance.register("start_onboarding"); + +// React Native / Expo +await Superwall.shared.register({ placement: "start_onboarding" }); +``` + +This approach lets you trigger onboarding based on specific user actions, like completing account setup or reaching a certain screen. + +## Building multi-page onboarding paywalls + +Superwall's [Navigation component](/paywall-editor-navigation-component) is perfect for creating multi-page onboarding experiences. Check out our [Simple Onboarding](https://superwall.com/templates?templateId=119147) template to see this in action. + +To create a multi-page onboarding paywall: + +1. In the paywall editor, click **+** to add a new element +2. Select **Navigation** under the "Base Elements" section +3. Add your onboarding content pages using [stacks](/paywall-editor-stacks) +4. Add buttons with tap behaviors to navigate between pages, or use transitions like Push, Fade, or Slide + +You can also use the [Slides component](/paywall-editor-slides-component) if you want gesture-driven navigation or the [Carousel component](/paywall-editor-carousel-component) if you want slides that auto-advance. + +## Personalizing content with dynamic values + +Use [variables](/paywall-editor-variables) and [dynamic values](/paywall-editor-dynamic-values) to show different content based on user attributes, device properties, or actions: + +**Show different messages based on device type:** +``` +if device.interfaceType is "ipad" + then "Welcome to the best iPad experience" +else + then "Welcome to your new favorite app" +``` + +**Display personalized content using user attributes:** +``` +if user.accountType is "premium" + then "Unlock your premium features" +else + then "Discover what you can do" +``` + +This means you can go to certain pages based off a button they tapped showing a survey, change wording, which products to show, and more. Rely on dynamic values and variables to completely customize flows. + +**Adjust layout based on onboarding progress:** + +Track which slide users are on using the [slides element variable](/paywall-editor-slides-component#tracking-or-updating-the-displayed-element-in-slides) and conditionally show/hide elements or change copy accordingly. + +## Tracking onboarding analytics + +To track onboarding metrics, you can use these [Superwall events](/tracking-analytics) and can they can also be sent to your own analytics service. Additionally, you can also use [custom paywall actions](/sdk/guides/advanced/custom-paywall-actions) to trigger specific tracking events when users interact with buttons or elements in your onboarding flow, giving you detailed insights into user behavior and drop-off points. + +## Best practices + +- **Length:** 3-10 pages is usually optimal for onboarding +- **Use user attributes:** Track onboarding completion and progress to avoid showing it repeatedly +- **Test variations:** Create multiple audiences to A/B test different onboarding flows +- **Make it dismissible:** Consider adding a skip option for returning users +- **Track analytics:** Monitor your onboarding completion rates in the campaign metrics + +Remember, since everything is managed through the dashboard, you can iterate on your onboarding experience without shipping app updates. + +## Going forward +Superwall is currently building out more tools for onboarding, such as text boxes and text entry, and will be available soon. \ No newline at end of file diff --git a/content/docs/dashboard/meta.json b/content/docs/dashboard/meta.json index 810772b9..7d34fe1b 100644 --- a/content/docs/dashboard/meta.json +++ b/content/docs/dashboard/meta.json @@ -25,6 +25,7 @@ "---Guides---", "guides/migrating-from-revenuecat-to-superwall", "guides/pre-launch-checklist", + "guides/using-superwall-for-onboarding-flows", "guides/tips-paywalls-based-on-placement", "guides/tips-abandoned-transaction-paywall", "guides/tips-first-touch-paywall", diff --git a/content/docs/expo/quickstart/tracking-subscription-state.mdx b/content/docs/expo/quickstart/tracking-subscription-state.mdx index 09a3013a..336c9636 100644 --- a/content/docs/expo/quickstart/tracking-subscription-state.mdx +++ b/content/docs/expo/quickstart/tracking-subscription-state.mdx @@ -1,5 +1,201 @@ --- -title: Tracking Subscription State +title: "Tracking Subscription State" +description: "Here's how to view whether or not a user is on a paid plan in React Native." --- -../../../shared/tracking-subscription-state.mdx \ No newline at end of file +Superwall tracks the subscription state of a user for you. However, there are times in your app where you need to know if a user is on a paid plan or not. For example, you might want to conditionally show certain UI elements or enable premium features based on their subscription status. + +## Using the `useUser` hook + +The easiest way to track subscription status in React Native is with the `useUser` hook from `expo-superwall`: + +```tsx +import { useUser } from "expo-superwall"; +import { useEffect, useState } from "react"; +import { View, Text } from "react-native"; + +function SubscriptionStatusExample() { + const { subscriptionStatus } = useUser(); + const [isPaidUser, setIsPaidUser] = useState(false); + + useEffect(() => { + if (subscriptionStatus?.status === "ACTIVE") { + console.log("User has active entitlements:", subscriptionStatus.entitlements); + setIsPaidUser(true); + } else { + console.log("User is on free plan"); + setIsPaidUser(false); + } + }, [subscriptionStatus]); + + return ( + + User Status: {isPaidUser ? "Pro" : "Free"} + Subscription: {subscriptionStatus?.status ?? "unknown"} + + ); +} +``` + +The `subscriptionStatus` object has the following structure: + +- `status`: Can be `"ACTIVE"`, `"INACTIVE"`, or `"UNKNOWN"` +- `entitlements`: An array of active entitlements (only present when status is `"ACTIVE"`) + +## Listening for subscription status changes + +You can also listen for real-time subscription status changes using the `useSuperwallEvents` hook: + +```tsx +import { useSuperwallEvents } from "expo-superwall"; +import { useState } from "react"; + +function App() { + const [isPro, setIsPro] = useState(false); + + useSuperwallEvents({ + onSubscriptionStatusChange: (status) => { + if (status.status === "ACTIVE") { + console.log("User upgraded to pro!"); + setIsPro(true); + } else { + console.log("User is on free plan"); + setIsPro(false); + } + }, + }); + + return ( + // Your app content + ); +} +``` + +## Checking for specific entitlements + +If your app has multiple subscription tiers (e.g., Bronze, Silver, Gold), you can check for specific entitlements: + +```tsx +import { useUser } from "expo-superwall"; + +function PremiumFeature() { + const { subscriptionStatus } = useUser(); + + const hasGoldTier = subscriptionStatus?.entitlements?.some( + (entitlement) => entitlement.id === "gold" + ); + + if (hasGoldTier) { + return ; + } + + return ; +} +``` + +## Setting subscription status + +When using Superwall with a custom purchase controller or third-party billing service, you need to manually update the subscription status. Here's how to sync with RevenueCat: + +```tsx +import { useUser } from "expo-superwall"; +import { useEffect } from "react"; +import Purchases from "react-native-purchases"; + +function SubscriptionSync() { + const { setSubscriptionStatus } = useUser(); + + useEffect(() => { + // Listen for RevenueCat customer info updates + const listener = Purchases.addCustomerInfoUpdateListener((customerInfo) => { + const entitlementIds = Object.keys(customerInfo.entitlements.active); + + setSubscriptionStatus({ + status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE", + entitlements: entitlementIds.map(id => ({ + id, + type: "SERVICE_LEVEL" + })) + }); + }); + + // Get initial customer info + const syncInitialStatus = async () => { + try { + const customerInfo = await Purchases.getCustomerInfo(); + const entitlementIds = Object.keys(customerInfo.entitlements.active); + + setSubscriptionStatus({ + status: entitlementIds.length === 0 ? "INACTIVE" : "ACTIVE", + entitlements: entitlementIds.map(id => ({ + id, + type: "SERVICE_LEVEL" + })) + }); + } catch (error) { + console.error("Failed to sync initial subscription status:", error); + } + }; + + syncInitialStatus(); + + return () => { + listener?.remove(); + }; + }, [setSubscriptionStatus]); + + return null; // This component just handles the sync +} +``` + +## Using subscription status emitter + +You can also listen to subscription status changes using the event emitter directly: + +```tsx +import Superwall from "expo-superwall"; +import { useEffect } from "react"; + +function SubscriptionListener() { + useEffect(() => { + const subscription = Superwall.shared.subscriptionStatusEmitter.addListener( + "change", + (status) => { + switch (status.status) { + case "ACTIVE": + console.log("Active entitlements:", status.entitlements); + break; + case "INACTIVE": + console.log("No active subscription"); + break; + case "UNKNOWN": + console.log("Subscription status unknown"); + break; + } + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + return null; +} +``` + +## Superwall checks subscription status for you + +Remember that the Superwall SDK uses its [audience filters](/campaigns-audience#matching-to-entitlements) for determining when to show paywalls. You generally don't need to wrap your calls to register placements with subscription status checks: + +```tsx +// ❌ Unnecessary +if (subscriptionStatus?.status !== "ACTIVE") { + await Superwall.shared.register({ placement: "campaign_trigger" }); +} + +// ✅ Just register the placement +await Superwall.shared.register({ placement: "campaign_trigger" }); +``` + +In your [audience filters](/campaigns-audience#matching-to-entitlements), you can specify whether the subscription state should be considered, which keeps your codebase cleaner and puts the "Should this paywall show?" logic where it belongs—in the Superwall dashboard. diff --git a/content/docs/flutter/guides/web-checkout/using-revenuecat.mdx b/content/docs/flutter/guides/web-checkout/using-revenuecat.mdx index 25e0fbb7..d74d236a 100644 --- a/content/docs/flutter/guides/web-checkout/using-revenuecat.mdx +++ b/content/docs/flutter/guides/web-checkout/using-revenuecat.mdx @@ -1,6 +1,217 @@ --- title: "Using RevenueCat" -description: "Handle a deep link in your app and use the delegate methods to link web checkouts with RevenueCat." +description: "Handle a deep link in your app and use the delegate methods to link web checkouts with RevenueCat in Flutter." --- -../../../../shared/web-checkout/using-revenuecat.mdx \ No newline at end of file +After purchasing from a web paywall, the user will be redirected to your app by a deep link to redeem their purchase on device. Please follow our [Post-Checkout Redirecting](/web-checkout-post-checkout-redirecting) guide to handle this user experience. + + + If you're using Superwall to handle purchases, then you don't need to do anything here. + + +You only need to use a `PurchaseController` if you want end-to-end control of the purchasing pipeline. The recommended way to use RevenueCat with Superwall is by putting it in observer mode. + +If you're using your own `PurchaseController`, you should follow our [Redeeming In-App](/web-checkout-linking-membership-to-iOS-app) guide. + +### Using a PurchaseController with RevenueCat + +If you're using RevenueCat, you'll need to follow [steps 1 to 4 in their guide](https://www.revenuecat.com/docs/web/integrations/stripe) to set up Stripe with RevenueCat. Then, you'll need to +associate the RevenueCat customer with the Stripe subscription IDs returned from redeeming the code. You can do this by extracting the ids from the `RedemptionResult` and sending them to RevenueCat's API +by using the `didRedeemLink()` delegate method: + +```dart +import 'package:superwall_flutter/superwall_flutter.dart'; +import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +class MySuperwallDelegate extends SuperwallDelegate { + // The user tapped on a deep link to redeem a code + @override + void willRedeemLink() { + print('[!] willRedeemLink'); + // Optionally show a loading indicator here + } + + // Superwall received a redemption result and validated the purchase with Stripe. + @override + void didRedeemLink(RedemptionResult result) async { + print('[!] didRedeemLink: $result'); + // Send Stripe IDs to RevenueCat to link purchases to the customer + + // Get a list of subscription ids tied to the customer + final stripeSubscriptionIds = result.stripeSubscriptionIds; + if (stripeSubscriptionIds == null || stripeSubscriptionIds.isEmpty) { + return; + } + + const revenueCatStripePublicAPIKey = 'strp.....'; // replace with your RevenueCat Stripe Public API Key + final appUserId = await Purchases.appUserID; + + // In the background, send requests to RevenueCat + for (final stripeSubscriptionId in stripeSubscriptionIds) { + try { + final url = Uri.parse('https://api.revenuecat.com/v1/receipts'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Platform': 'stripe', + 'Authorization': 'Bearer $revenueCatStripePublicAPIKey', + }, + body: jsonEncode({ + 'app_user_id': appUserId, + 'fetch_token': stripeSubscriptionId, + }), + ); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body); + print('[!] Success: linked $stripeSubscriptionId to user $appUserId: $json'); + } else { + print('[!] Error: unable to link $stripeSubscriptionId to user $appUserId. Status: ${response.statusCode}'); + } + } catch (error) { + print('[!] Error: unable to link $stripeSubscriptionId to user $appUserId: $error'); + } + } + + // After all network calls complete, invalidate the cache + try { + final customerInfo = await Purchases.getCustomerInfo(); + + /// If you're using Purchases.customerInfoStream, or keeping Superwall Entitlements in sync + /// via RevenueCat's PurchasesDelegate methods, you don't need to do anything here. Those methods will be + /// called automatically when this call fetches the most up to date customer info, ignoring any local caches. + + /// Otherwise, if you're manually calling Purchases.getCustomerInfo to keep Superwall's entitlements + /// in sync, you should use the newly updated customer info here to do so. + + /// You could always access web entitlements here as well + /// final webEntitlements = Superwall.shared.entitlements?.web; + + // Perform UI updates, like letting the user know their subscription was redeemed + print('[!] Customer info updated after redemption'); + } catch (error) { + print('[!] Error fetching customer info: $error'); + } + } +} +``` + +Set up the delegate when configuring Superwall: + +```dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await Superwall.configure('pk_your_api_key'); + + // Set the delegate + Superwall.shared.setDelegate(MySuperwallDelegate()); + + runApp(MyApp()); +} +``` + + + If you call `logIn` from RevenueCat's SDK, then you need to call the logic you've implemented + inside `didRedeemLink()` again. For example, that means if `logIn` was invoked from + RevenueCat, you'd either abstract out this logic above into a function to call again, or simply + call this function directly. + + +The web entitlements will be returned along with other existing entitlements in the `CustomerInfo` object accessible via RevenueCat's SDK. + +If you're logging in and out of RevenueCat, make sure to resend the Stripe subscription IDs to RevenueCat's endpoint after logging in. + +### Alternative implementation using async/await properly + +Here's a cleaner implementation that properly handles async operations: + +```dart +class MySuperwallDelegate extends SuperwallDelegate { + @override + void willRedeemLink() { + print('[!] willRedeemLink'); + // Show loading indicator + } + + @override + void didRedeemLink(RedemptionResult result) { + // Don't use async here directly, spawn a separate task + _handleRedemption(result); + } + + Future _handleRedemption(RedemptionResult result) async { + final stripeSubscriptionIds = result.stripeSubscriptionIds; + if (stripeSubscriptionIds == null || stripeSubscriptionIds.isEmpty) { + print('[!] No Stripe subscription IDs found'); + return; + } + + const revenueCatStripePublicAPIKey = 'strp.....'; + final appUserId = await Purchases.appUserID; + + // Link each subscription to RevenueCat + await Future.wait( + stripeSubscriptionIds.map((stripeSubscriptionId) async { + try { + await _linkSubscriptionToRevenueCat( + stripeSubscriptionId, + appUserId, + revenueCatStripePublicAPIKey, + ); + } catch (e) { + print('[!] Failed to link $stripeSubscriptionId: $e'); + } + }), + ); + + // Refresh customer info + try { + await Purchases.getCustomerInfo(); + print('[!] Successfully refreshed customer info'); + } catch (e) { + print('[!] Failed to refresh customer info: $e'); + } + } + + Future _linkSubscriptionToRevenueCat( + String stripeSubscriptionId, + String appUserId, + String apiKey, + ) async { + final url = Uri.parse('https://api.revenuecat.com/v1/receipts'); + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Platform': 'stripe', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'app_user_id': appUserId, + 'fetch_token': stripeSubscriptionId, + }), + ); + + if (response.statusCode == 200) { + print('[!] Successfully linked $stripeSubscriptionId to $appUserId'); + } else { + throw Exception('Failed to link subscription: ${response.statusCode}'); + } + } +} +``` + +Remember to add the `http` package to your `pubspec.yaml`: + +```yaml +dependencies: + http: ^1.1.0 + purchases_flutter: ^6.0.0 + superwall_flutter: ^1.0.0 +``` diff --git a/content/docs/flutter/quickstart/tracking-subscription-state.mdx b/content/docs/flutter/quickstart/tracking-subscription-state.mdx index 09a3013a..e00e9ee4 100644 --- a/content/docs/flutter/quickstart/tracking-subscription-state.mdx +++ b/content/docs/flutter/quickstart/tracking-subscription-state.mdx @@ -1,5 +1,301 @@ --- -title: Tracking Subscription State +title: "Tracking Subscription State" +description: "Here's how to view whether or not a user is on a paid plan in Flutter." --- -../../../shared/tracking-subscription-state.mdx \ No newline at end of file +Superwall tracks the subscription state of a user for you. However, there are times in your app where you need to know if a user is on a paid plan or not. For example, you might want to conditionally show certain UI elements or enable premium features based on their subscription status. + +## Using subscriptionStatus stream + +The easiest way to track subscription status in Flutter is by listening to the `subscriptionStatus` stream: + +```dart +class _MyAppState extends State { + StreamSubscription? _subscription; + SubscriptionStatus _currentStatus = SubscriptionStatus.unknown; + + @override + void initState() { + super.initState(); + + _subscription = Superwall.shared.subscriptionStatus.listen((status) { + setState(() { + _currentStatus = status; + }); + + switch (status) { + case SubscriptionStatus.active: + print('User has active subscription'); + _showPremiumContent(); + break; + case SubscriptionStatus.inactive: + print('User is on free plan'); + _showFreeContent(); + break; + case SubscriptionStatus.unknown: + print('Subscription status unknown'); + _showLoadingState(); + break; + } + }); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} +``` + +The `SubscriptionStatus` enum has three possible values: + +- `SubscriptionStatus.unknown` - Status is not yet determined +- `SubscriptionStatus.active` - User has an active subscription +- `SubscriptionStatus.inactive` - User has no active subscription + +## Using SuperwallBuilder widget + +For reactive UI updates based on subscription status, use the `SuperwallBuilder` widget: + +```dart +SuperwallBuilder( + builder: (context, subscriptionStatus) { + switch (subscriptionStatus) { + case SubscriptionStatus.active: + return PremiumContent(); + case SubscriptionStatus.inactive: + return FreeContent(); + default: + return LoadingIndicator(); + } + }, +) +``` + +This widget automatically rebuilds whenever the subscription status changes, making it perfect for conditionally rendering UI: + +```dart +class SubscriptionStatusDisplay extends StatelessWidget { + @override + Widget build(BuildContext context) { + return SuperwallBuilder( + builder: (context, status) => Center( + child: Text('Subscription Status: $status'), + ), + ); + } +} +``` + +## Using StreamBuilder + +You can also use Flutter's `StreamBuilder` for more control over the stream subscription: + +```dart +class PremiumFeatureButton extends StatelessWidget { + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Superwall.shared.subscriptionStatus, + builder: (context, snapshot) { + final status = snapshot.data ?? SubscriptionStatus.unknown; + + return ElevatedButton( + onPressed: status == SubscriptionStatus.active + ? _accessPremiumFeature + : _showPaywall, + child: Text( + status == SubscriptionStatus.active + ? 'Access Premium Feature' + : 'Upgrade to Premium', + ), + ); + }, + ); + } + + void _accessPremiumFeature() { + // Access premium feature + } + + void _showPaywall() { + Superwall.shared.registerPlacement('premium_feature'); + } +} +``` + +## Checking subscription status programmatically + +If you need to check the subscription status at a specific moment without listening to the stream: + +```dart +Future checkSubscription() async { + // Note: You'll need to get the current value from the stream + final subscription = Superwall.shared.subscriptionStatus.listen((status) { + if (status == SubscriptionStatus.active) { + // User is subscribed + enablePremiumFeatures(); + } else { + // User is not subscribed + showUpgradePrompt(); + } + }); + + // Remember to cancel when done + subscription.cancel(); +} +``` + +## Setting subscription status + +When using Superwall with a custom purchase controller or third-party billing service, you need to manually update the subscription status. Here's how to sync with RevenueCat: + +```dart +class RCPurchaseController extends PurchaseController { + + Future syncSubscriptionStatus() async { + try { + final customerInfo = await Purchases.getCustomerInfo(); + final hasActiveSubscription = customerInfo.entitlements.active.isNotEmpty; + + if (hasActiveSubscription) { + final entitlements = customerInfo.entitlements.active.keys + .map((id) => Entitlement(id: id)) + .toSet(); + await Superwall.shared.setSubscriptionStatus( + SubscriptionStatusActive(entitlements: entitlements) + ); + } else { + await Superwall.shared.setSubscriptionStatus( + SubscriptionStatusInactive() + ); + } + } catch (e) { + print('Failed to sync subscription status: $e'); + } + } + + @override + Future purchaseFromAppStore(String productId) async { + try { + final result = await Purchases.purchaseProduct(productId); + + if (result.isSuccess) { + // Sync status after successful purchase + await syncSubscriptionStatus(); + return PurchaseResult.purchased; + } + + return PurchaseResult.failed; + } catch (e) { + return PurchaseResult.failed; + } + } +} +``` + +You can also listen for subscription changes from your payment service: + +```dart +void setupSubscriptionListener() { + myPaymentService.addSubscriptionStatusListener((subscriptionInfo) { + final entitlements = subscriptionInfo.entitlements.active.keys + .map((id) => Entitlement(id: id)) + .toSet(); + final hasActiveSubscription = subscriptionInfo.isActive; + + if (hasActiveSubscription) { + Superwall.shared.setSubscriptionStatus( + SubscriptionStatusActive(entitlements: entitlements) + ); + } else { + Superwall.shared.setSubscriptionStatus( + SubscriptionStatusInactive() + ); + } + }); +} +``` + +## Using SuperwallDelegate + +You can also listen for subscription status changes using the `SuperwallDelegate`: + +```dart +class _MyAppState extends State implements SuperwallDelegate { + + @override + void initState() { + super.initState(); + + // Set delegate + Superwall.shared.setDelegate(this); + } + + @override + void subscriptionStatusDidChange(SubscriptionStatus newValue) { + print('Subscription status changed to: $newValue'); + + switch (newValue) { + case SubscriptionStatus.active: + print('User is now premium'); + _handlePremiumUser(); + break; + case SubscriptionStatus.inactive: + print('User is now free'); + _handleFreeUser(); + break; + case SubscriptionStatus.unknown: + print('Status unknown'); + break; + } + } + + void _handlePremiumUser() { + // Update UI or app state for premium user + } + + void _handleFreeUser() { + // Update UI or app state for free user + } +} +``` + +## Handling subscription expiry + +If you need to check for subscription expiry manually: + +```dart +Future checkSubscriptionExpiry() async { + final expiryDate = await MyPaymentService.getSubscriptionExpiry(); + + if (expiryDate.isBefore(DateTime.now())) { + // Subscription has expired + await Superwall.shared.setSubscriptionStatus( + SubscriptionStatusInactive() + ); + + // Show renewal prompt + _showRenewalPrompt(); + } +} +``` + +## Superwall checks subscription status for you + +Remember that the Superwall SDK uses its [audience filters](/campaigns-audience#matching-to-entitlements) for determining when to show paywalls. You generally don't need to wrap your calls to register placements with subscription status checks: + +```dart +// ❌ Unnecessary +final subscription = Superwall.shared.subscriptionStatus.listen((status) { + if (status != SubscriptionStatus.active) { + Superwall.shared.registerPlacement('campaign_trigger'); + } +}); + +// ✅ Just register the placement +Superwall.shared.registerPlacement('campaign_trigger'); +``` + +In your [audience filters](/campaigns-audience#matching-to-entitlements), you can specify whether the subscription state should be considered, which keeps your codebase cleaner and puts the "Should this paywall show?" logic where it belongs—in the Superwall dashboard. diff --git a/content/docs/images/campaign-filter-add-placement-param.png b/content/docs/images/campaign-filter-add-placement-param.png new file mode 100644 index 00000000..645c460f Binary files /dev/null and b/content/docs/images/campaign-filter-add-placement-param.png differ diff --git a/content/docs/images/campaign-filter-add-user-prop-filter.png b/content/docs/images/campaign-filter-add-user-prop-filter.png new file mode 100644 index 00000000..98bfb383 Binary files /dev/null and b/content/docs/images/campaign-filter-add-user-prop-filter.png differ diff --git a/content/docs/images/campaign-filter-add-user-prop-named.png b/content/docs/images/campaign-filter-add-user-prop-named.png new file mode 100644 index 00000000..68bd0b89 Binary files /dev/null and b/content/docs/images/campaign-filter-add-user-prop-named.png differ diff --git a/content/docs/images/campaign-filter-add-user-prop.png b/content/docs/images/campaign-filter-add-user-prop.png new file mode 100644 index 00000000..1597795a Binary files /dev/null and b/content/docs/images/campaign-filter-add-user-prop.png differ