Files
purchases-ios/Sources/Events/FeatureEvents/FeatureEvent.swift
T
c23c3cdf40 UI events for paywall component interactions (#6523)
* Initial setup for paywall control interaction events for v2 paywalls (PWENG-9)

* Use tabs container name for component_name and tab name for component_value (PWENG-9)

* Use tabs container name for tab and toggle control interaction (PWENG-9)

* Introduce paywall control interaction events for legacy paywalls (PWENG-9)

* Align documented code (PWENG-9)

* Fix tvOS 13 build by replacing TapGesture with onTapGesture in FooterView (PWENG-9)

* Fix RevenueCatUITests build by inlining JSON in ToPresentedOverridesTests (PWENG-9)

* Nest paywall control interaction type as PaywallEvent.ControlType (PWENG-9)

* Add componentURL to paywall control interaction events (PWENG-9)

* Add text control type for paywall markdown link analytics (PWENG-9)

* Only track carousel interactions for user drags (PWENG-9)

* Open footer links via OpenURLAction with Link on macOS (PWENG-9)

* Updated documentation for the tab control type (PWENG-9)

* Omit nil name/transition when encoding ButtonComponent (PWENG-9)

* Fix lint errors (PWENG-9)

* Track footer restore tap before restore interceptor (PWENG-9)

* Add CodingKeys for componentUrl in control interaction events (PWENG-9)

* Revert "Add CodingKeys for componentUrl in control interaction events (PWENG-9)"

This reverts commit 57a7525cca.

* Update the control type as a stand-alone enum (PWENG-9)

* Remove trivial unit tests (PWENG-9)

* Update baseline swiftinterface files for `monika/UI-events/paywall-control-interaction` (#6528)

* Remove trivial unit tests (PWENG-9)

* [skip ci] Update baseline swiftinterface files

---------

Co-authored-by: Monika Mateska <monika.mateska@revenuecat.com>
Co-authored-by: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>

* Use a fitting parameter name (PWENG-9)

* Track navigation metadata for control interactions

Add navigation metadata (originIndex, destinationIndex, originContextName, destinationContextName, defaultIndex) to paywall control interactions and propagate them through the codebase. Updated PurchaseHandler.trackControlInteraction signature and PaywallEvent.ControlInteractionData to include the new fields and include them in event serialization (FeatureEventsRequest, FeatureEvent map). Updated UI components to populate navigation info: Carousel now reports origin/destination/default page indices and page context names; Tabs now carries tab names/context mapping, initial/default index and reports origin/destination info on selection. Paywall component models now include optional `name` for StackComponent and Tab. Tests and swiftinterface API files were updated accordingly.

* Add PaywallEventTracker and control logger

Introduce PaywallEventTracker to centralize paywall event tracking and provide a lightweight ControlInteractionLogger environment key for views. Refactor PurchaseHandler to delegate event logic to PaywallEventTracker and wire the controlInteractionLogger into paywall views and components (replacing direct PurchaseHandler calls). Update event discriminator from "paywall_control_interaction" to "paywall_component_interacted" and adjust related networking and event mapping. Add unit/UI tests for PaywallEventTracker and ControlInteractionLogger and remove redundant control-interaction tests from PurchaseHandlerTests. Includes background event dispatcher wiring and minor view adjustments to use the new logger.

* Rename control interaction to component interaction

Replace "control"-centric interaction names with "component"-centric ones across the paywall UI and event pipeline. This updates types, methods, environment keys and loggers (e.g. ControlInteraction -> ComponentInteraction), event payload fields and networking mapping, and updates all unit/UITests to match. This standardizes terminology for component interactions (tabs, carousels, buttons, text links) and adjusts the event tracking surface accordingly — note this is a breaking rename for any external API/consumers referencing the old identifiers.

* Rename ControlInteraction to ComponentInteraction

Rename PaywallEvent control interaction API: replace case `controlInteraction` with `componentInteraction`, `ControlInteractionData` with `ComponentInteractionData`, and the `controlInteractionData` property with `componentInteractionData` across platform swiftinterface files (iOS, macOS, tvOS, visionOS, watchOS and their simulators). Updates equality, codable/sendable conformance and encoder/decoder signatures to match the new type name for consistency and clearer naming.

* Use shared PaywallEventTracker instance

Add a private PaywallEventTracker property to PaywallView and PaywallsV2View and pass its componentInteractionLogger into the environment instead of creating a new PaywallEventTracker inline. This ensures a single tracker instance is used for component interaction logging within each view, avoiding ephemeral tracker creation and keeping tracking state consistent.

* Use shared PaywallEventTracker & inject tracker

Add a singleton PaywallEventTracker.shared and wire it into UI and purchasing code. Replace local tracker instances in paywall views with PaywallEventTracker.shared.componentInteractionLogger, and update PurchaseHandler to accept an injectable PaywallEventTracker (defaulting to .shared). Update test helpers and mocks to construct and pass explicit eventTracker instances, adjust LoadingPaywallView test setup to provide a dedicated tracker, and add new source/test files to the Xcode project file. These changes allow deterministic event-tracking in tests and avoid duplicated tracker instances at runtime.

* Remove convenience trackComponentInteraction overload

Remove the parameter-based convenience overload of PaywallEventTracker.trackComponentInteraction and consolidate usage to the single method that accepts PaywallEvent.ComponentInteractionData. Update unit tests to construct ComponentInteractionData via its initializer and call trackComponentInteraction(data) accordingly. This removes duplicated API surface and centralizes event construction.

* Remove EventDispatcher from PurchaseHandler

Remove the EventDispatcher abstraction and related properties from PurchaseHandler: delete the EventDispatcher typealias, backgroundEventDispatcher and testEventDispatcher, and the eventDispatcher stored property and init parameter. Update mapping helpers to stop propagating an eventDispatcher. Update tests and test helpers to pass inline Task { await work() } closures where needed. Files changed: PurchaseHandler.swift, PurchaseHandler+TestData.swift, and related tests in Tests/RevenueCatUITests/Purchasing.

* add name to stack partial

* Update PaywallAPI.swift

* Add testEventDispatcher back to tests

Introduce PaywallEventTracker.testEventDispatcher that uses Task { } (non-detached) so test events inherit caller context and aren't deprioritized by Task.detached(priority: .background) on iOS 26+ CI. Update PurchaseHandler test data and unit tests to pass this test dispatcher when constructing the event tracker to ensure prompt event delivery in tests.

* Add plan-selection metadata to paywall component interaction events

* Add package-selection sheet tracking (PWENG-15)

* [skip ci] Update baseline swiftinterface files (#6564)

* Route paywall control analytics through ComponentInteractionData factories (PWENG-15)

* [skip ci] Update baseline swiftinterface files (#6565)

* Make PaywallEventTracker thread-safe with locked state (PWENG-15)

* Clarify unknown button action telemetry as diagnostics (PWENG-15)

* Isolate paywall analytics state per session in shared tracker (PWENG-15)

* Fix swiftlint issues (PWENG-15)

* Prune PaywallEventTracker session state (PWENG-15)

* Fix ) SPM RevenueCatUl Tests

* Move paywall test event dispatcher out of RevenueCatUI production code (PWENG-15)

* Add purchase button interaction tracking (#6575)

* Add purchase button interaction tracking

Add tracking for purchase button taps: introduce ComponentInteractionType.purchaseButton and a paywallPurchaseButtonAction factory that captures componentName, componentValue, componentURL, currentPackageIdentifier and currentProductIdentifier. Expose componentName and customWebCheckoutUrl on the PurchaseButton view model and log the interaction from PurchaseButtonComponentView before initiating the purchase. Extend PurchaseButtonComponent to include an optional name and make Method conform to CustomStringConvertible to provide stable string values for logging. Update previews to include the new name field where applicable.

Co-Authored-By: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>

* Use context-aware web checkout URL

Replace direct use of viewModel.customWebCheckoutUrl with viewModel.urlForWebCheckout(packageContext: packageContext)?.url when logging the purchase button interaction. This ensures the logged componentURL is derived using the current package context (if available) and pulls the URL from the computed web checkout result rather than the raw custom value.

Co-Authored-By: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>

* Add tests for purchase button interactions

Add comprehensive tests covering paywall purchase button behavior across components and tracking.

- Tests/RevenueCatUITests/Purchasing/ControlInteractionLoggerTests.swift: add factory tests for PaywallEvent.ComponentInteractionData.paywallPurchaseButtonAction (componentType, name, value, URL, current package/product identifiers, and fully-populated case).
- Tests/RevenueCatUITests/Purchasing/PaywallEventTrackerTests.swift: add tracker tests to ensure component interaction events include purchase button metadata and handle nil name/no-package cases.
- Tests/UnitTests/Paywalls/Components/PurchaseButtonComponentTests.swift: add decoding tests for name present/absent, include name:nil in expected models, add Method.description tests and ComponentInteractionType raw value test.

These tests validate decoding, description strings, raw values, and that tracking records the expected purchase button metadata.

Co-Authored-By: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>

* Include name in PurchaseButtonComponent eq/hash

Add `name` to the PurchaseButtonComponent Hashable and Equatable implementations. This ensures instances with different `name` values produce distinct hashes and are not considered equal, preventing incorrect comparisons or collection behavior.

Co-Authored-By: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>

* [skip ci] Update baseline swiftinterface files (#6577)

---------

Co-authored-by: Monika Mateska <45938914+MonikaMateska@users.noreply.github.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>

* Route paywall component interaction logger through PurchaseHandler (PWENG-15)

* Log V1 purchase button component interactions (PWENG-30)

* Consolidate ComponentInteractionData factory extensions (PWENG-15)

* Defer Paywalls V2 purchase button analytics until checkout is resolved (PWENG-30)

* Fix lint issue (PWENG-15)

* Use NSLock.withLock instead of custom perform (PWENG-15)

* Fix failing test (PWENG-15)

* Fix failing tests (PWENG-15)

* Log Paywalls V2 purchase button interaction before purchase gate (PWENG-15)

* Fallback tier selector interaction value to tier id (PWENG-15)

* Record paywall_close before session reset on UIKit dismiss (PWENG-15)

* Fix lint issues (PWENG-15)

* Expose tier selector interaction value for tests (PWENG-15)

* correct modifers

* Emit paywall close after exit offer dismiss (PWENG-15)

* Consolidate PaywallsV2 impression tracking and eligibility in addPaywallModifiers (PWENG-15)

* Derive tab toggle from selectedTabId to avoid sync race (PWENG-15)

* Fix lint issue (PWENG-15)

* Fix lint issue (PWENG-15)

* Clear tab package selection when user picks a root-only package (PWENG-15)

* Fix Swift 5.8 compile error in purchase button method description (PWENG-15)

---------

Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
Co-authored-by: Alexander Repty <alexander.repty@mac.com>
Co-authored-by: Jacob Rakidzich <Jacob@JacobZivanDesign.com>
Co-authored-by: Alexander Repty <alex.repty@revenuecat.com>
2026-04-16 15:30:01 +02:00

244 lines
8.2 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// FeatureEvent.swift
//
// Created by Cesar de la Vega on 6/11/24.
protocol FeatureEvent: Encodable, Sendable {
var feature: Feature { get }
var eventDiscriminator: String? { get }
/// Whether this event should be stored and sent to the backend.
/// WIP: Some PaywallEvents are not yet supported by the backend.
/// We should implement support for these events in the backend first
/// and then we can remove this `shouldStoreEvent` (as it will be always `true`)
var shouldStoreEvent: Bool { get }
/// Whether tracking this event should trigger an immediate flush to the backend.
var isPriorityEvent: Bool { get }
}
extension FeatureEvent {
/// By default, all events should be stored.
var shouldStoreEvent: Bool { true }
/// By default, events are not priority events.
var isPriorityEvent: Bool { false }
}
// MARK: - Dictionary Mapping
extension FeatureEvent {
/// Converts this event into a dictionary suitable for hybrid SDK consumption.
func toMap() -> [String: Any] {
switch self {
case let event as PaywallEvent:
return event.paywallMap()
case let event as CustomerCenterEvent:
return event.customerCenterImpressionMap()
case let event as CustomerCenterAnswerSubmittedEvent:
return event.customerCenterAnswerSubmittedMap()
case let event as CustomPaywallEvent:
return event.customPaywallEventMap()
default:
return [
"discriminator": "unknown",
"type": "unknown",
"class_name": String(describing: type(of: self))
]
}
}
}
private extension PaywallEvent {
func paywallMap() -> [String: Any] {
let typeName: String = {
switch self {
case .impression: return "paywall_impression"
case .cancel: return "paywall_cancel"
case .close: return "paywall_close"
case .exitOffer: return "paywall_exit_offer"
case .purchaseInitiated: return "paywall_purchase_initiated"
case .purchaseError: return "paywall_purchase_error"
case .componentInteraction: return "paywall_component_interacted"
}
}()
var result: [String: Any] = [
"discriminator": "paywalls",
"type": typeName,
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"offering_id": self.data.offeringIdentifier,
"paywall_revision": self.data.paywallRevision,
"session_id": self.data.sessionIdentifier.uuidString,
"display_mode": self.data.displayMode.identifier,
"locale": self.data.localeIdentifier,
"dark_mode": self.data.darkMode
]
if let interaction = self.componentInteractionData {
interaction.mergeIntoPaywallFeatureMap(&result)
}
return result
}
}
private extension PaywallEvent.ComponentInteractionData {
func mergeIntoPaywallFeatureMap(_ result: inout [String: Any]) {
self.mergeCoreFields(into: &result)
self.mergePackageIdentifiers(into: &result)
self.mergeProductIdentifiers(into: &result)
}
func mergeCoreFields(into result: inout [String: Any]) {
result["component_type"] = self.componentType.rawValue
result["component_value"] = self.componentValue
if let name = self.componentName {
result["component_name"] = name
}
if let url = self.componentURL {
result["component_url"] = url.absoluteString
}
if let originIndex = self.originIndex {
result["origin_index"] = originIndex
}
if let destinationIndex = self.destinationIndex {
result["destination_index"] = destinationIndex
}
if let originContextName = self.originContextName {
result["origin_context_name"] = originContextName
}
if let destinationContextName = self.destinationContextName {
result["destination_context_name"] = destinationContextName
}
if let defaultIndex = self.defaultIndex {
result["default_index"] = defaultIndex
}
}
func mergePackageIdentifiers(into result: inout [String: Any]) {
if let originPackageIdentifier = self.originPackageIdentifier {
result["origin_package_id"] = originPackageIdentifier
}
if let destinationPackageIdentifier = self.destinationPackageIdentifier {
result["destination_package_id"] = destinationPackageIdentifier
}
if let defaultPackageIdentifier = self.defaultPackageIdentifier {
result["default_package_id"] = defaultPackageIdentifier
}
if let currentPackageIdentifier = self.currentPackageIdentifier {
result["current_package_id"] = currentPackageIdentifier
}
if let resultingPackageIdentifier = self.resultingPackageIdentifier {
result["resulting_package_id"] = resultingPackageIdentifier
}
}
func mergeProductIdentifiers(into result: inout [String: Any]) {
if let originProductIdentifier = self.originProductIdentifier {
result["origin_product_id"] = originProductIdentifier
}
if let destinationProductIdentifier = self.destinationProductIdentifier {
result["destination_product_id"] = destinationProductIdentifier
}
if let defaultProductIdentifier = self.defaultProductIdentifier {
result["default_product_id"] = defaultProductIdentifier
}
if let currentProductIdentifier = self.currentProductIdentifier {
result["current_product_id"] = currentProductIdentifier
}
if let resultingProductIdentifier = self.resultingProductIdentifier {
result["resulting_product_id"] = resultingProductIdentifier
}
}
}
private extension CustomerCenterEvent {
func customerCenterImpressionMap() -> [String: Any] {
return [
"discriminator": "customer_center",
"type": "customer_center_impression",
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"dark_mode": self.data.darkMode,
"locale": self.data.localeIdentifier,
"display_mode": self.data.displayMode.identifier
]
}
}
private extension CustomPaywallEvent {
func customPaywallEventMap() -> [String: Any] {
let typeName: String = {
switch self {
case .impression: return "custom_paywall_impression"
}
}()
var result: [String: Any] = [
"discriminator": "custom_paywall_event",
"type": typeName,
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970
]
if let paywallId = self.data.paywallId {
result["paywall_id"] = paywallId
}
if let offeringId = self.data.offeringId {
result["offering_id"] = offeringId
}
return result
}
}
private extension CustomerCenterAnswerSubmittedEvent {
func customerCenterAnswerSubmittedMap() -> [String: Any] {
var result: [String: Any] = [
"discriminator": "customer_center",
"type": "customer_center_survey_option_chosen",
"id": self.creationData.id.uuidString,
"timestamp": self.creationData.date.millisecondsSince1970,
"dark_mode": self.data.darkMode,
"locale": self.data.localeIdentifier,
"display_mode": self.data.displayMode.identifier,
"survey_option_id": self.data.surveyOptionID,
"path": self.data.path.rawValue,
"revision_id": self.data.revisionID
]
if let url = self.data.url {
result["url"] = url.absoluteString
}
return result
}
}