Skip to content
7 changes: 7 additions & 0 deletions RevenueCat/Scripts/PresentedOfferingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public PresentedOfferingContext(JSONNode response)
}
}

public PresentedOfferingContext(string offeringIdentifier)
{
OfferingIdentifier = offeringIdentifier;
PlacementIdentifier = null;
TargetingContext = null;
}

public override string ToString()
{
return $"{nameof(OfferingIdentifier)}: {OfferingIdentifier}\n" +
Expand Down
70 changes: 52 additions & 18 deletions RevenueCatUI/Scripts/PaywallOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,57 @@

namespace RevenueCatUI
{
internal abstract class OfferingSelection
{
internal sealed class OfferingType : OfferingSelection
{
public Purchases.Offering Offering { get; }

public OfferingType(Purchases.Offering offering)
{
Offering = offering;
}

internal override Purchases.Offering GetOffering() => Offering;
internal override string GetOfferingIdentifier() => Offering.Identifier;
internal override Purchases.PresentedOfferingContext GetPresentedOfferingContext() =>
Offering.AvailablePackages != null && Offering.AvailablePackages.Count > 0
? Offering.AvailablePackages[0].PresentedOfferingContext
: null;
}

internal sealed class IdOnly : OfferingSelection
{
public string OfferingId { get; }
private Purchases.PresentedOfferingContext _presentedOfferingContext;

public IdOnly(string offeringId)
{
OfferingId = offeringId;
_presentedOfferingContext = new Purchases.PresentedOfferingContext(offeringId);
}

internal override Purchases.Offering GetOffering() => null;
internal override string GetOfferingIdentifier() => OfferingId;
internal override Purchases.PresentedOfferingContext GetPresentedOfferingContext() => _presentedOfferingContext;
}

internal abstract Purchases.Offering GetOffering();
internal abstract string GetOfferingIdentifier();
internal abstract Purchases.PresentedOfferingContext GetPresentedOfferingContext();
}

/// <summary>
/// Options for configuring paywall presentation.
/// </summary>
[Serializable]
public class PaywallOptions
{
/// <summary>
/// The offering to present.
/// If not provided, the current offering will be used.
/// </summary>
public Purchases.Offering Offering { get; set; }
internal readonly OfferingSelection _offeringSelection;

/// <summary>
/// Whether to display a close button on the paywall.
/// Only applicable for original template paywalls, ignored for V2 Paywalls.
/// </summary>
public bool DisplayCloseButton { get; set; } = false;

internal string OfferingIdentifier => Offering?.Identifier;

internal Purchases.PresentedOfferingContext PresentedOfferingContext =>
Offering?.AvailablePackages != null && Offering.AvailablePackages.Count > 0
? Offering.AvailablePackages[0].PresentedOfferingContext
: null;
internal bool DisplayCloseButton { get; }
internal string OfferingIdentifier => _offeringSelection?.GetOfferingIdentifier();
internal Purchases.PresentedOfferingContext PresentedOfferingContext => _offeringSelection?.GetPresentedOfferingContext();

/// <summary>
/// Creates a new PaywallOptions instance.
Expand All @@ -34,6 +61,7 @@ public class PaywallOptions
/// <param name="displayCloseButton">Whether to display a close button. Only applicable for original template paywalls, ignored for V2 Paywalls.</param>
public PaywallOptions(bool displayCloseButton = false)
{
_offeringSelection = null;
DisplayCloseButton = displayCloseButton;
}

Expand All @@ -44,7 +72,13 @@ public PaywallOptions(bool displayCloseButton = false)
/// <param name="displayCloseButton">Whether to display a close button. Only applicable for original template paywalls, ignored for V2 Paywalls.</param>
public PaywallOptions(Purchases.Offering offering, bool displayCloseButton = false)
{
Offering = offering;
_offeringSelection = offering != null ? new OfferingSelection.OfferingType(offering) : null;
DisplayCloseButton = displayCloseButton;
}

internal PaywallOptions(string offeringIdentifier, bool displayCloseButton = false)
{
_offeringSelection = !string.IsNullOrEmpty(offeringIdentifier) ? new OfferingSelection.IdOnly(offeringIdentifier) : null;
DisplayCloseButton = displayCloseButton;
}
}
Expand Down
194 changes: 180 additions & 14 deletions RevenueCatUI/Scripts/PaywallsBehaviour.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,198 @@
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

namespace RevenueCatUI
{
/// <summary>
/// MonoBehaviour helper that forwards to the static PaywallsPresenter API so paywalls can be driven from scenes.
/// MonoBehaviour component for presenting RevenueCat Paywalls from the Unity Editor.
/// Provides an alternative to PaywallsPresenter for developers who prefer configuring
/// Paywalls through Unity's Inspector interface.
/// </summary>
[AddComponentMenu("RevenueCat/Paywalls Behaviour")]
public class PaywallsBehaviour : MonoBehaviour
{
/// <summary>
/// Presents a paywall configured in the RevenueCat dashboard.
/// </summary>
/// <param name="options">Options for presenting the paywall.</param>
/// <returns>A <see cref="PaywallResult"/> describing the outcome.</returns>
public async Task<PaywallResult> PresentPaywall(PaywallOptions options = null)
[Header("Paywall Options")]
[Tooltip("The identifier of the offering to present. Leave empty to use the current offering.")]
[SerializeField] private string offeringIdentifier;

[Tooltip("Whether to display a close button on the paywall (only for original template RevenueCat Paywalls).")]
[SerializeField] private bool displayCloseButton = false;

[Header("Conditional Presentation")]
[Tooltip("If set, the paywall will only be presented if the user doesn't have this entitlement.")]
[SerializeField] private string requiredEntitlementIdentifier;

[Header("Events")]
[Tooltip("Invoked when the user completes a purchase and the paywall is dismissed.")]
public UnityEvent OnPurchased = new UnityEvent();
Copy link
Contributor Author

@vegaro vegaro Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These UnityEvents can accept generics, but won't work with a custom class like PaywallResult, so I decided to split it into multiple events, that way they can be set in the editor. The alternative is to use a primitive type (string), but I think that limits us more if we ever want to send something in the events:

[Serializable]
public class StringEvent : UnityEvent<string> { }

[Header("Events")]
public StringEvent OnPaywallResult = new StringEvent();

private void HandleResult(PaywallResult result)
{
    OnPaywallResult?.Invoke(result.Result.ToString());
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we ever need to send something like UnityEvent<CustomerInfo>, it's still possible but devs need to hook to it via code (it doesn't show up in the Editor's dropdown):

[Serializable]
public class CustomerInfoEvent : UnityEvent<CustomerInfo> { }

[Header("Events")]
public CustomerInfoEvent OnCustomerInfoUpdated = new CustomerInfoEvent();
using UnityEngine;

public class CustomerInfoHandler : MonoBehaviour
{
    [SerializeField] private Purchases purchases;

    void Start()
    {
        // Subscribe to the event
        purchases.OnCustomerInfoUpdated.AddListener(HandleCustomerInfo);
    }

    void OnDestroy()
    {
        // Unsubscribe
        purchases.OnCustomerInfoUpdated.RemoveListener(HandleCustomerInfo);
    }

    private void HandleCustomerInfo(Purchases.CustomerInfo customerInfo)
    {
        Debug.Log($"Customer info updated: {customerInfo.ActiveSubscriptions.Count} active subscriptions");
        
        foreach (var entitlement in customerInfo.Entitlements.Active)
        {
            Debug.Log($"Active entitlement: {entitlement.Key}");
        }
    }
}

Or we could remove the parameter and inform developers to fetch the CustomerInfo themselves which I believe is the Unity standard since it works in the Editor:

[Header("Events")]
[Tooltip("Invoked when customer info is updated. Use GetCustomerInfo() to fetch the latest data.")]
public UnityEvent OnCustomerInfoUpdated = new UnityEvent();

private void NotifyCustomerInfoUpdate()
{
    OnCustomerInfoUpdated?.Invoke();
}


[Tooltip("Invoked when the user restores purchases and the paywall is dismissed.")]
public UnityEvent OnRestored = new UnityEvent();

[Tooltip("Invoked when the user cancels the paywall and the paywall is dismissed.")]
public UnityEvent OnCancelled = new UnityEvent();

[Tooltip("Invoked when the paywall was not presented, for example when the user already has the required entitlement).")]
public UnityEvent OnNotPresented = new UnityEvent();

[Tooltip("Invoked when an error occurs.")]
public UnityEvent OnError = new UnityEvent();

private bool isPresenting = false;

public string OfferingIdentifier
{
get => offeringIdentifier;
set => offeringIdentifier = value;
}

public bool DisplayCloseButton
{
get => displayCloseButton;
set => displayCloseButton = value;
}

public string RequiredEntitlementIdentifier
{
return await PaywallsPresenter.Present(options);
get => requiredEntitlementIdentifier;
set => requiredEntitlementIdentifier = value;
}

/// <summary>
/// Presents a paywall only if the user does not have the specified entitlement.
/// Presents the paywall with the configured options.
/// Can be called from Unity UI buttons or programmatically.
/// </summary>
/// <param name="requiredEntitlementIdentifier">Entitlement identifier to check before presenting.</param>
/// <param name="options">Options for presenting the paywall.</param>
/// <returns>A <see cref="PaywallResult"/> describing the outcome.</returns>
public async Task<PaywallResult> PresentPaywallIfNeeded(string requiredEntitlementIdentifier, PaywallOptions options = null)
public async void PresentPaywall()
{
return await PaywallsPresenter.PresentIfNeeded(requiredEntitlementIdentifier, options);
if (isPresenting)
{
Debug.LogWarning("[RevenueCatUI] Paywall is already being presented.");
return;
}

isPresenting = true;

try
{
var options = CreateOptions();
PaywallResult result;

if (!string.IsNullOrEmpty(requiredEntitlementIdentifier))
{
result = await PaywallsPresenter.PresentIfNeeded(requiredEntitlementIdentifier, options);
}
else
{
result = await PaywallsPresenter.Present(options);
}

HandleResult(result);
}
catch (Exception e)
{
Debug.LogError($"[RevenueCatUI] Exception in PaywallsBehaviour: {e.Message}");
HandleResult(PaywallResult.Error);
}
finally
{
isPresenting = false;
}
}

private async void PresentPaywallIfNeeded(string entitlementIdentifier)
{
if (string.IsNullOrEmpty(entitlementIdentifier))
{
Debug.LogError("[RevenueCatUI] Entitlement identifier cannot be null or empty.");
HandleResult(PaywallResult.Error);
return;
}

if (isPresenting)
{
Debug.LogWarning("[RevenueCatUI] Paywall is already being presented.");
return;
}

isPresenting = true;

try
{
var options = CreateOptions();
var result = await PaywallsPresenter.PresentIfNeeded(entitlementIdentifier, options);
HandleResult(result);
}
catch (Exception e)
{
Debug.LogError($"[RevenueCatUI] Exception in PaywallsBehaviour: {e.Message}");
HandleResult(PaywallResult.Error);
}
finally
{
isPresenting = false;
}
}

private PaywallOptions CreateOptions()
{
if (string.IsNullOrEmpty(offeringIdentifier))
{
return new PaywallOptions(displayCloseButton);
}

return new PaywallOptions(offeringIdentifier, displayCloseButton);
}

private void HandleResult(PaywallResult result)
{
if (result == null)
{
Debug.Log("[RevenueCatUI] Received null PaywallResult.");
OnError?.Invoke();
return;
}

switch (result.Result)
{
case PaywallResultType.Purchased:
if (OnPurchased.GetPersistentEventCount() == 0)
{
Debug.Log("[RevenueCatUI] Paywall purchase completed but OnPurchased event has no listeners.");
}
OnPurchased?.Invoke();
break;
case PaywallResultType.Restored:
if (OnRestored.GetPersistentEventCount() == 0)
{
Debug.Log("[RevenueCatUI] Paywall restore completed but OnRestored event has no listeners.");
}
OnRestored?.Invoke();
break;
case PaywallResultType.Cancelled:
if (OnCancelled.GetPersistentEventCount() == 0)
{
Debug.Log("[RevenueCatUI] Paywall cancelled but OnCancelled event has no listeners.");
}
OnCancelled?.Invoke();
break;
case PaywallResultType.NotPresented:
if (OnNotPresented.GetPersistentEventCount() == 0)
{
Debug.Log("[RevenueCatUI] Paywall not presented but OnNotPresented event has no listeners.");
}
OnNotPresented?.Invoke();
break;
case PaywallResultType.Error:
if (OnError.GetPersistentEventCount() == 0)
{
Debug.Log("[RevenueCatUI] Paywall error occurred but OnError event has no listeners.");
}
OnError?.Invoke();
break;
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ public Task<PaywallResult> PresentPaywallAsync(PaywallOptions options)
_current = new TaskCompletionSource<PaywallResult>();
try
{
var offering = options?.OfferingIdentifier;
var offeringIdentifier = options?.OfferingIdentifier;
var displayCloseButton = options?.DisplayCloseButton ?? false;
var presentedOfferingContextJson = options?.PresentedOfferingContext?.ToJsonString();

Debug.Log($"[RevenueCatUI][Android] presentPaywall offering='{offering ?? "<null>"}', displayCloseButton={displayCloseButton}");
Debug.Log($"[RevenueCatUI][Android] presentPaywall offering='{offeringIdentifier ?? "<null>"}', " +
$"displayCloseButton={displayCloseButton}");
var currentActivity = AndroidApplication.currentActivity;
_plugin.CallStatic("presentPaywall", new object[] { currentActivity, offering, presentedOfferingContextJson, displayCloseButton });
_plugin.CallStatic("presentPaywall", new object[] { currentActivity, offeringIdentifier, presentedOfferingContextJson, displayCloseButton });
}
catch (Exception e)
{
Expand Down Expand Up @@ -83,12 +84,13 @@ public Task<PaywallResult> PresentPaywallIfNeededAsync(string requiredEntitlemen
_current = new TaskCompletionSource<PaywallResult>();
try
{
var offering = options?.OfferingIdentifier;
var offeringIdentifier = options?.OfferingIdentifier;
var displayCloseButton = options?.DisplayCloseButton ?? true;
var presentedOfferingContextJson = options?.PresentedOfferingContext?.ToJsonString();
Debug.Log($"[RevenueCatUI][Android] presentPaywallIfNeeded entitlement='{requiredEntitlementIdentifier}', offering='{offering ?? "<null>"}', displayCloseButton={displayCloseButton}");
Debug.Log($"[RevenueCatUI][Android] presentPaywallIfNeeded entitlement='{requiredEntitlementIdentifier}', '" +
$"offering='{offeringIdentifier ?? "<null>"}', displayCloseButton={displayCloseButton}");
var currentActivity = AndroidApplication.currentActivity;
_plugin.CallStatic("presentPaywallIfNeeded", new object[] { currentActivity, requiredEntitlementIdentifier, offering, presentedOfferingContextJson, displayCloseButton });
_plugin.CallStatic("presentPaywallIfNeeded", new object[] { currentActivity, requiredEntitlementIdentifier, offeringIdentifier, presentedOfferingContextJson, displayCloseButton });
}
catch (Exception e)
{
Expand Down
Loading