diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179377bd1..bff62a244e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,16 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.16.4 +## 4.17.0 + +### Enhancements + +- Adds the Customer Center: a native, self-service screen where users can view their subscriptions and purchases, restore purchases, manage or cancel a subscription, request a refund, change plans, contact support, answer exit surveys and open any subscription for its details. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`), including an accent colour for light and dark. Requires iOS 15+. +- Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. +- The Customer Center's update banner now finds the published version itself, by looking the app up on the App Store, so `latestAppVersion` no longer has to be kept current by hand. Set `SuperwallOptions.customerCenter.support.checksAppStoreForUpdates = false` to opt out, or keep setting `latestAppVersion` — a configured version always wins and skips the lookup. The check is skipped on TestFlight, sandbox and simulator builds, whose version is normally ahead of the App Store. Note that Apple phases releases in over seven days while the lookup sees a new version immediately, so early in a release some customers may be prompted to update before the build reaches them. +- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. A web purchase whose product has no display name in the catalogue is headed by the entitlement it unlocks instead — never by its raw identifier — and picks the name up as soon as the catalogue returns one. +- Improved the Customer Center for subscriptions bought on the web (Stripe, Paddle). The management row is now labelled "Manage subscription" rather than "Cancel subscription", since a web management page does more than cancel; it stays visible when no management URL is configured, explaining that the link is in the customer's emailed receipt, instead of disappearing and leaving them with no action at all; and feedback surveys are skipped for web flows, which hand off to a browser rather than completing in the app. +- `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it renders into your navigation bar without modifying it, and its own screens are pushed onto your stack as further view controllers. ### Fixes diff --git a/Examples/Advanced/Advanced.xcodeproj/project.pbxproj b/Examples/Advanced/Advanced.xcodeproj/project.pbxproj index c6567daf6a..702132a8fb 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.pbxproj +++ b/Examples/Advanced/Advanced.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 888F48DB27DBA8A9009C74A3 /* Rubik-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 888F48DA27DBA8A9009C74A3 /* Rubik-Bold.ttf */; }; 88A801D028EAE717004244CA /* SuperwallSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A801CF28EAE717004244CA /* SuperwallSubscriptionView.swift */; }; 88C49B712D9C2B2300DFE335 /* Delegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C49B702D9C2B2300DFE335 /* Delegate.swift */; }; + 0397C21A03B38109D5BA5BEA /* CustomerCenterExampleDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */; }; 88EE539627DF6D0A00F1FFFB /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */; }; 88F9F1E327E21718004FCE83 /* InfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88F9F1E227E21718004FCE83 /* InfoView.swift */; }; /* End PBXBuildFile section */ @@ -47,6 +48,7 @@ 888F48DA27DBA8A9009C74A3 /* Rubik-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Rubik-Bold.ttf"; sourceTree = ""; }; 88A801CF28EAE717004244CA /* SuperwallSubscriptionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SuperwallSubscriptionView.swift; sourceTree = ""; }; 88C49B702D9C2B2300DFE335 /* Delegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Delegate.swift; sourceTree = ""; }; + 9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterExampleDelegate.swift; sourceTree = ""; }; 88EE539127DF64BB00F1FFFB /* Superwall_Advanced-Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Superwall_Advanced-Products.storekit"; sourceTree = ""; }; 88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 88F9F1E227E21718004FCE83 /* InfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoView.swift; sourceTree = ""; }; @@ -104,6 +106,7 @@ 888F488727DA6B0F009C74A3 /* SuperwallAdvancedApp.swift */, 88EE539527DF6D0A00F1FFFB /* WelcomeView.swift */, 888F48C227DB8584009C74A3 /* HomeView.swift */, + 9D68675E18A19CC047DCB958 /* CustomerCenterExampleDelegate.swift */, ); path = Advanced; sourceTree = ""; @@ -271,6 +274,7 @@ 887A26712D118388002B8E9B /* RCPurchaseController.swift in Sources */, 887A266F2D11832A002B8E9B /* SWPurchaseController.swift in Sources */, 888F488827DA6B0F009C74A3 /* SuperwallAdvancedApp.swift in Sources */, + 0397C21A03B38109D5BA5BEA /* CustomerCenterExampleDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Examples/Advanced/Advanced/CustomerCenterExampleDelegate.swift b/Examples/Advanced/Advanced/CustomerCenterExampleDelegate.swift new file mode 100644 index 0000000000..a7a0951607 --- /dev/null +++ b/Examples/Advanced/Advanced/CustomerCenterExampleDelegate.swift @@ -0,0 +1,36 @@ +// +// CustomerCenterExampleDelegate.swift +// Advanced +// +// Created by Claude on 20/08/2026. +// + +import Foundation +import SuperwallKit + +/// An example `CustomerCenterDelegate` that prints each callback it receives. +/// +/// `Superwall.shared.presentCustomerCenter(delegate:)` retains this for the duration of the +/// presentation, so it's safe to create a fresh instance each time you present. +final class CustomerCenterExampleDelegate: CustomerCenterDelegate { + func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) { + print("[Customer Center] shouldRestorePurchases") + resume(true) + } + + func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) { + print("[Customer Center] didSelect action: \(action), purchase: \(String(describing: purchase))") + } + + func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) { + print("[Customer Center] didCompleteSurvey: \(surveyId), optionId: \(optionId), action: \(action)") + } + + func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) { + print("[Customer Center] didCompleteRefundRequestFor: \(productId), status: \(status)") + } + + func customerCenterDidDismiss() { + print("[Customer Center] didDismiss") + } +} diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index cfe5bd13d3..55370d4cef 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -30,6 +30,31 @@ struct HomeView: View { Superwall.shared.userAttributes["firstName"] as? String } + /// Presents the Customer Center with a code-built configuration and a delegate that prints + /// each callback it receives. See `CustomerCenterExampleDelegate`. + private func presentCustomerCenter() { + let configuration = CustomerCenterConfiguration( + managementScreen: .init( + paths: [ + .init(id: "restore", type: .restore), + .init(id: "change_plan", type: .changePlan()), + .init(id: "refund", type: .refund()), + .init(id: "manage_subscription", type: .manageSubscription), + .init(id: "faq", type: .url(URL(string: "https://superwall.com/faq")!, title: "FAQ", openMethod: .inApp)), + .init(id: "contact_support", type: .contactSupport) + ] + ), + noPurchasesScreen: .init( + paths: [.init(id: "restore", type: .restore)] + ), + support: .init(email: "support@superwall.com") + ) + Superwall.shared.presentCustomerCenter( + configuration: configuration, + delegate: CustomerCenterExampleDelegate() + ) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { @@ -82,6 +107,9 @@ struct HomeView: View { page = .diamond } } + BrandedButton(title: "Customer Center") { + presentCustomerCenter() + } } .padding(.horizontal) } diff --git a/Examples/Basic/Basic/HomeView.swift b/Examples/Basic/Basic/HomeView.swift index 98b03fdaa4..f8bc3ada8c 100644 --- a/Examples/Basic/Basic/HomeView.swift +++ b/Examples/Basic/Basic/HomeView.swift @@ -76,6 +76,9 @@ struct HomeView: View { page = .gated } } + BrandedButton(title: "Customer Center") { + Superwall.shared.presentCustomerCenter() + } } .padding(.horizontal) } diff --git a/Examples/Basic/Basic/Superwall_Basic-Products.storekit b/Examples/Basic/Basic/Superwall_Basic-Products.storekit index ee8085e579..f378e152a4 100644 --- a/Examples/Basic/Basic/Superwall_Basic-Products.storekit +++ b/Examples/Basic/Basic/Superwall_Basic-Products.storekit @@ -17,55 +17,51 @@ ], "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, "_compatibilityTimeRate" : { "3" : 6 }, + "_disableDialogs" : false, "_failTransactionsEnabled" : false, "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, "_storefront" : "USA", "_storeKitErrors" : [ { - "current" : null, "enabled" : false, "name" : "Load Products" }, { - "current" : null, "enabled" : false, "name" : "Purchase" }, { - "current" : null, "enabled" : false, "name" : "Verification" }, { - "current" : null, "enabled" : false, "name" : "App Store Sync" }, { - "current" : null, "enabled" : false, "name" : "Subscription Status" }, { - "current" : null, "enabled" : false, "name" : "App Transaction" }, { - "current" : null, "enabled" : false, "name" : "Manage Subscriptions Sheet" }, { - "current" : null, "enabled" : false, "name" : "Refund Request Sheet" }, { - "current" : null, "enabled" : false, "name" : "Offer Code Redeem Sheet" } @@ -83,6 +79,15 @@ { "adHocOffers" : [ + ], + "billingPlans" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "commitmentDisplayPrice" : "39.99", + "displayPrice" : "39.99", + "internalID" : "7ACEE564", + "isEnabled" : true + } ], "codeOffers" : [ @@ -91,7 +96,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "E02B772A", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "Access to pro features", @@ -112,7 +119,7 @@ } ], "version" : { - "major" : 4, + "major" : 5, "minor" : 0 } } diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index eb366a8352..3ec2b3bdce 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -1210,6 +1210,73 @@ enum InternalSuperwallEvent { } } + struct CustomerCenterOpen: TrackableSuperwallEvent { + let screen: String + /// How the Customer Center was presented: `"sheet"` or `"embedded"`. + let presentation: String + var superwallEvent: SuperwallEvent { .customerCenterOpen(screen: screen) } + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { + ["screen": screen, "presentation": presentation] + } + } + + struct CustomerCenterClose: TrackableSuperwallEvent { + let superwallEvent: SuperwallEvent = .customerCenterClose + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { [:] } + } + + struct CustomerCenterAction: TrackableSuperwallEvent { + let action: SuperwallKit.CustomerCenterAction + let pathId: String + let productId: String? + var superwallEvent: SuperwallEvent { .customerCenterAction(action: action, pathId: pathId, productId: productId) } + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { + var params: [String: Any] = ["action": action.analyticsName, "path_id": pathId] + if let productId { params["product_id"] = productId } + if case .url(let url) = action { params["url"] = url.absoluteString } + if case .custom(let identifier) = action { params["custom_identifier"] = identifier } + return params + } + } + + struct CustomerCenterSurveyResponse: TrackableSuperwallEvent { + let surveyId: String + let optionId: String + let action: SuperwallKit.CustomerCenterAction + let pathId: String + let productId: String? + var superwallEvent: SuperwallEvent { + .customerCenterSurveyResponse( + surveyId: surveyId, + optionId: optionId, + action: action, + pathId: pathId, + productId: productId + ) + } + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { + var params: [String: Any] = [ + "survey_id": surveyId, "option_id": optionId, "action": action.analyticsName, "path_id": pathId + ] + if let productId { params["product_id"] = productId } + return params + } + } + + struct CustomerCenterRefundRequest: TrackableSuperwallEvent { + let productId: String + let status: CustomerCenterRefundStatus + var superwallEvent: SuperwallEvent { .customerCenterRefundRequest(productId: productId, status: status) } + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { + ["product_id": productId, "status": status.analyticsName] + } + } + enum PaywallPreloadState { case start case complete diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index ebb25d5a39..17f0c5589b 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -359,6 +359,28 @@ public enum SuperwallEvent { /// When the test mode modal is closed. case testModeModalClose + /// When the Customer Center is presented. `screen` is `management` or `no_purchases`. + case customerCenterOpen(screen: String) + + /// When the Customer Center is dismissed. + case customerCenterClose + + /// When the user taps a path in the Customer Center. + case customerCenterAction(action: CustomerCenterAction, pathId: String, productId: String?) + + /// When the user answers a Customer Center survey. + // swiftlint:disable:next enum_case_associated_values_count + case customerCenterSurveyResponse( + surveyId: String, + optionId: String, + action: CustomerCenterAction, + pathId: String, + productId: String? + ) + + /// When a refund request started from the Customer Center completes. + case customerCenterRefundRequest(productId: String, status: CustomerCenterRefundStatus) + /// When a user navigates to a page in a multi-page paywall. case paywallPageView( paywallInfo: PaywallInfo, @@ -564,6 +586,16 @@ extension SuperwallEvent { return .init(objcEvent: .testModeModalOpen) case .testModeModalClose: return .init(objcEvent: .testModeModalClose) + case .customerCenterOpen: + return .init(objcEvent: .customerCenterOpen) + case .customerCenterClose: + return .init(objcEvent: .customerCenterClose) + case .customerCenterAction: + return .init(objcEvent: .customerCenterAction) + case .customerCenterSurveyResponse: + return .init(objcEvent: .customerCenterSurveyResponse) + case .customerCenterRefundRequest: + return .init(objcEvent: .customerCenterRefundRequest) case .paywallPageView: return .init(objcEvent: .paywallPageView) } diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index fd57b6c413..f371ba0450 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -4,7 +4,7 @@ // // Created by Yusuf Tör on 07/11/2022. // -// swiftlint:disable file_length +// swiftlint:disable file_length type_body_length import Foundation @@ -263,6 +263,17 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When install attribution is resolved or fails to resolve. case attributionMatch + /// When the Customer Center is presented. + case customerCenterOpen + /// When the Customer Center is dismissed. + case customerCenterClose + /// When the user taps a path in the Customer Center. + case customerCenterAction + /// When the user answers a Customer Center survey. + case customerCenterSurveyResponse + /// When a refund request started from the Customer Center completes. + case customerCenterRefundRequest + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } @@ -427,6 +438,16 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "testModeModal_open" case .testModeModalClose: return "testModeModal_close" + case .customerCenterOpen: + return "customerCenter_open" + case .customerCenterClose: + return "customerCenter_close" + case .customerCenterAction: + return "customerCenter_action" + case .customerCenterSurveyResponse: + return "customerCenter_surveyResponse" + case .customerCenterRefundRequest: + return "customerCenter_refundRequest" case .paywallPageView: return "paywall_page_view" } diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index c39e7ab207..31a9db0c4c 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -22,6 +22,9 @@ public final class SuperwallOptions: NSObject, Encodable { /// Configures the appearance and behaviour of paywalls. public var paywalls = PaywallOptions() + /// Configures the Customer Center presented via ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``. + public var customerCenter = CustomerCenterConfiguration.default + /// A mapping of local resource IDs to ``AssetResource`` values. /// /// Use this to serve paywall assets (images, videos, Lottie animations) from the app diff --git a/Sources/SuperwallKit/CustomerCenter/Actions/StoreKitTransactionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Actions/StoreKitTransactionLookup.swift new file mode 100644 index 0000000000..92bd62d593 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Actions/StoreKitTransactionLookup.swift @@ -0,0 +1,31 @@ +// +// StoreKitTransactionLookup.swift +// SuperwallKit +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation +import StoreKit + +protocol StoreKitTransactionLooking: Sendable { + func latestTransactionID(for productId: String) async -> UInt64? + func isFamilyShared(productId: String) async -> Bool +} + +@available(iOS 15.0, *) +struct StoreKitTransactionLookup: StoreKitTransactionLooking { + func latestTransactionID(for productId: String) async -> UInt64? { + guard case .verified(let transaction)? = await Transaction.latest(for: productId) else { + return nil + } + return transaction.id + } + + func isFamilyShared(productId: String) async -> Bool { + guard case .verified(let transaction)? = await Transaction.latest(for: productId) else { + return false + } + return transaction.ownershipType == .familyShared + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift new file mode 100644 index 0000000000..c34eae96fe --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -0,0 +1,159 @@ +// +// CustomerCenterManager.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import UIKit + +/// Builds the dependencies backing ``CustomerCenterView`` and owns the single Customer Center +/// presentation for ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``. +@available(iOS 15.0, *) +@MainActor +final class CustomerCenterManager { + private unowned let container: DependencyContainer + private weak var presentedController: CustomerCenterViewController? + + /// Strongly retains the delegate passed to `present`/`presentObjc` for the duration of the + /// presentation. `CustomerCenterDelegateAdapter` only holds a `weak` reference to the delegate + /// (so the view controller itself never retains it), so this is the retention the public API + /// docs promise: "present via `Superwall.shared.presentCustomerCenter(delegate:)`, which retains + /// the delegate while the Customer Center is presented." + private var retainedDelegate: AnyObject? + + /// Test hook: the number of times `present` has actually started a presentation. + private(set) var presentCount = 0 + + /// Test hook: whether `present`/`dismiss` animate. Always `true` in production. A hostless test + /// target's run loop never drives a real transition-coordinator animation to completion (there's + /// no live display link committing frames), so tests set this to `false` to get UIKit's + /// completion handlers to fire deterministically. + var presentsAnimated = true + + /// Test hook: the currently presented controller, if any. Lets tests trigger the exact same + /// dismissal cleanup UIKit's `viewDidDisappear` would (via its `onDismiss`), without depending on + /// a live view-controller-transition animation actually completing — unavailable in a hostless + /// test target, where UIKit registers a presentation's bookkeeping synchronously but never + /// actually finishes loading the presented view into a window. + var presentedControllerForTesting: CustomerCenterViewController? { presentedController } + + init(container: DependencyContainer) { + self.container = container + } + + /// Whether a Customer Center is currently presented. + var isPresented: Bool { presentedController != nil } + + /// Resolves the configuration to use: `override` if provided, otherwise the value configured via + /// ``SuperwallOptions/customerCenter``. + func resolveConfiguration(_ override: CustomerCenterConfiguration?) -> CustomerCenterConfiguration { + override ?? container.configManager.options.customerCenter + } + + /// Builds the view model backing ``CustomerCenterView``, using `Superwall.shared`'s dependency + /// container. Used by `CustomerCenterViewController`'s public initializers. + static func makeViewModel(configuration: CustomerCenterConfiguration?) -> CustomerCenterViewModel { + if !Superwall.isInitialized { + Logger.debug( + logLevel: .error, + scope: .customerCenter, + message: "Customer Center was created before Superwall.configure(...) — it will not show " + + "purchases. Configure the SDK first." + ) + } + let container = Superwall.shared.dependencyContainer + let resolved = configuration ?? container.configManager.options.customerCenter + return CustomerCenterViewModel( + configuration: resolved, + dependencies: .live(container: container, configuration: resolved), + strings: .bundled() + ) + } + + /// Presents the Customer Center for a Swift ``CustomerCenterDelegate``. + func present( + configuration: CustomerCenterConfiguration?, + from presenter: UIViewController?, + delegate: CustomerCenterDelegate?, + onDismiss: (() -> Void)? + ) { + present( + configuration: configuration, + from: presenter, + adapter: CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil), + retaining: delegate, + onDismiss: onDismiss + ) + } + + /// Presents the Customer Center for an Objective-C ``CustomerCenterDelegateObjc``. + func presentObjc( + configuration: CustomerCenterConfiguration?, + from presenter: UIViewController?, + objcDelegate: CustomerCenterDelegateObjc?, + onDismiss: (() -> Void)? + ) { + present( + configuration: configuration, + from: presenter, + adapter: CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: objcDelegate), + retaining: objcDelegate, + onDismiss: onDismiss + ) + } + + private func present( + configuration: CustomerCenterConfiguration?, + from presenter: UIViewController?, + adapter: CustomerCenterDelegateAdapter, + retaining delegate: AnyObject?, + onDismiss: (() -> Void)? + ) { + guard !isPresented else { + Logger.debug(logLevel: .warn, scope: .customerCenter, message: "Customer Center is already presented.") + return + } + var presenting = presenter ?? UIViewController.topMostViewController + while let presented = presenting?.presentedViewController, !presented.isBeingDismissed { + presenting = presented + } + guard let presenting else { + Logger.debug( + logLevel: .error, + scope: .customerCenter, + message: "No view controller available to present the Customer Center." + ) + return + } + let resolved = resolveConfiguration(configuration) + let viewModel = CustomerCenterViewModel( + configuration: resolved, + dependencies: .live(container: container, configuration: resolved), + strings: .bundled() + ) + let controller = CustomerCenterViewController(viewModel: viewModel, adapter: adapter, presentationStyle: .modal) + controller.onDismiss = { [weak self] in + self?.presentedController = nil + self?.retainedDelegate = nil + onDismiss?() + } + retainedDelegate = delegate + presentedController = controller + presentCount += 1 + presenting.present(controller, animated: presentsAnimated) + } + + /// Dismisses the presented Customer Center, if any. + func dismiss(completion: (() -> Void)?) { + guard let controller = presentedController else { + completion?() + return + } + controller.dismiss(animated: presentsAnimated) { [weak self] in + self?.presentedController = nil + self?.retainedDelegate = nil + completion?() + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift new file mode 100644 index 0000000000..e09b950d48 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift @@ -0,0 +1,51 @@ +// +// CustomerCenterDelegate.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Receives Customer Center events. All methods have default implementations. +/// +/// The view controller does not retain its delegate. Keep a strong reference to it for the +/// duration of the presentation — or present via `Superwall.shared.presentCustomerCenter(delegate:)`, +/// which retains the delegate while the Customer Center is presented. +@available(iOS 15.0, *) +public protocol CustomerCenterDelegate: AnyObject { + /// Called before purchases are restored. Call `resume(true)` to continue or `resume(false)` to cancel. + func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) + /// Called whenever the user taps a path, including custom and URL paths, before the action runs. + func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) + /// Called when the user answers a survey attached to a path. + func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) + /// Called when a refund request sheet finishes. + func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) + /// Called when the Customer Center is dismissed. + func customerCenterDidDismiss() +} + +@available(iOS 15.0, *) +public extension CustomerCenterDelegate { + func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) { resume(true) } + func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) {} + func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) {} + func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) {} + func customerCenterDidDismiss() {} +} + +/// Objective-C variant of ``CustomerCenterDelegate``. +/// +/// The view controller does not retain its delegate. Keep a strong reference to it for the +/// duration of the presentation — or present via `Superwall.shared.presentCustomerCenter(delegate:)`, +/// which retains the delegate while the Customer Center is presented. +@available(iOS 15.0, *) +@objc(SWKCustomerCenterDelegate) +public protocol CustomerCenterDelegateObjc: AnyObject { + @objc optional func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) + @objc optional func customerCenter(didSelect action: CustomerCenterActionObjc, for purchase: SubscriptionTransaction?) + @objc optional func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterActionObjc) + @objc optional func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) + @objc optional func customerCenterDidDismiss() +} diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift new file mode 100644 index 0000000000..a3098fe849 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift @@ -0,0 +1,167 @@ +// +// AppStoreVersionLookup.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Foundation + +/// Supplies the version currently published on the App Store, for the update banner to compare +/// the installed version against. +protocol CustomerCenterAppStoreVersionProviding { + /// The published version, or `nil` when it can't be determined. Never throws: the banner is + /// advisory, so every failure resolves to "don't show it". + func latestAppStoreVersion() async -> String? +} + +/// Reads the published version from Apple's public lookup endpoint. +/// +/// Deliberately the public endpoint rather than App Store Connect: the Connect API authenticates +/// with a signed JWT, and the private key that signs it can't ship inside a client. +/// +/// Known limitation — phased release. Apple rolls a release out over seven days, but the lookup +/// reports the new version to everyone the moment it's live. During that window some customers +/// are told to update to a build they can't install yet; tapping through lands them on a store +/// page still offering what they already have. Accepted rather than solved: the alternatives are +/// holding the banner back a fixed number of days (which delays it for genuinely stale installs +/// too) or not checking at all. Hosts who can't tolerate it should set `latestAppVersion` and +/// control the timing themselves. +struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { + /// How long a looked-up version is trusted before being fetched again. + static let cacheDuration: TimeInterval = 60 * 60 * 24 + + private static let versionKey = "com.superwall.customerCenter.latestAppStoreVersion" + private static let fetchedAtKey = "com.superwall.customerCenter.latestAppStoreVersionFetchedAt" + private static let regionKey = "com.superwall.customerCenter.latestAppStoreVersionRegion" + + let bundleId: String? + /// Two-letter region to scope the lookup to. Versions differ by region during a phased release, + /// so asking for the wrong one can report a version this device can't install. + /// + /// This is the device's *region setting*, not the App Store storefront that actually decides + /// which listing exists — the two are configured independently. The storefront would be the + /// better source, but `Storefront.countryCode` is a three-letter code and this endpoint takes + /// two, with no first-party conversion between them. When they disagree and the app isn't + /// published in the device's region the lookup finds nothing, which hides the banner and logs. + let regionCode: String? + let defaults: UserDefaults + let session: URLSession + let now: () -> Date + + init( + bundleId: String? = Bundle.main.bundleIdentifier, + regionCode: String? = AppStoreVersionLookup.deviceRegionCode, + defaults: UserDefaults = .standard, + session: URLSession = .shared, + now: @escaping () -> Date = Date.init + ) { + self.bundleId = bundleId + self.regionCode = regionCode + self.defaults = defaults + self.session = session + self.now = now + } + + /// Guarded rather than reading `Locale.regionCode` directly, which is deprecated at iOS 16 — + /// `DeviceHelper.regionCode` makes the same split. + static var deviceRegionCode: String? { + if #available(iOS 16, *) { + return Locale.autoupdatingCurrent.language.region?.identifier + } + return Locale.autoupdatingCurrent.regionCode + } + + func latestAppStoreVersion() async -> String? { + if let cached = cachedVersion() { + return cached + } + guard let url = lookupURL() else { + Logger.debug( + logLevel: .warn, + scope: .customerCenter, + message: "Can't check the App Store for updates: no bundle identifier." + ) + return nil + } + do { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + Logger.debug( + logLevel: .warn, + scope: .customerCenter, + message: "App Store version lookup returned an unexpected response." + ) + return nil + } + guard let version = Self.parseVersion(from: data) else { + // An empty `results` array is the normal shape for an app that isn't on the store yet, or + // a bundle identifier that doesn't match the published one. Worth saying out loud, since + // silently never showing the banner is hard to diagnose. + Logger.debug( + logLevel: .warn, + scope: .customerCenter, + message: "No App Store listing found for bundle id \(bundleId ?? "nil") " + + "in region \(regionCode ?? "none"). The update banner won't show. If the app is " + + "published elsewhere, that region is the likely reason. Set `latestAppVersion` to " + + "warn without a lookup." + ) + return nil + } + cache(version) + return version + } catch { + Logger.debug( + logLevel: .warn, + scope: .customerCenter, + message: "App Store version lookup failed.", + error: error + ) + return nil + } + } + + static func parseVersion(from data: Data) -> String? { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + let version = results.first?["version"] as? String, + !version.isEmpty + else { + return nil + } + return version + } + + private func lookupURL() -> URL? { + guard let bundleId, !bundleId.isEmpty else { return nil } + var components = URLComponents(string: "https://itunes.apple.com/lookup") + var items = [URLQueryItem(name: "bundleId", value: bundleId)] + if let regionCode, !regionCode.isEmpty { + items.append(URLQueryItem(name: "country", value: regionCode)) + } + components?.queryItems = items + return components?.url + } + + private func cachedVersion() -> String? { + guard + let version = defaults.string(forKey: Self.versionKey), + let fetchedAt = defaults.object(forKey: Self.fetchedAtKey) as? Date, + now().timeIntervalSince(fetchedAt) < Self.cacheDuration, + // A version is only the answer for the region it was fetched for. Without this, someone who + // changes region is served the previous store's answer for the rest of the day — and that + // is exactly when the answer is most likely to be wrong. + defaults.string(forKey: Self.regionKey) == regionCode + else { + return nil + } + return version + } + + private func cache(_ version: String) { + defaults.set(version, forKey: Self.versionKey) + defaults.set(now(), forKey: Self.fetchedAtKey) + defaults.set(regionCode, forKey: Self.regionKey) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift new file mode 100644 index 0000000000..a267f43ca8 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift @@ -0,0 +1,32 @@ +// +// AppVersionComparator.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Compares marketing version strings on up to three leading numeric components. +enum AppVersionComparator { + /// Returns `true` only when both strings parse and `installed` < `latest`. + static func isInstalledVersion(_ installed: String?, olderThan latest: String?) -> Bool { + guard + let installed = parse(installed), + let latest = parse(latest) + else { + return false + } + return installed.lexicographicallyPrecedes(latest) + } + + /// Parses "1.2.3", "1.2", "1" → [major, minor, patch]; returns nil if the first component isn't numeric. + static func parse(_ version: String?) -> [Int]? { + guard let version else { return nil } + let parts = version.split(separator: ".", omittingEmptySubsequences: false).prefix(3).map { Int($0) } + guard let first = parts.first, first != nil else { return nil } + var result = parts.map { $0 ?? 0 } + while result.count < 3 { result.append(0) } + return result + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift new file mode 100644 index 0000000000..8543bc3217 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -0,0 +1,154 @@ +// +// CustomerCenterPathResolver.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +struct PathResolutionContext { + var purchase: PurchasePresentation? + var product: ProductDisplayInfo? + var isFamilyShared = false + var supportEmailAvailable: Bool + var webManagementURL: URL? + var isChangePlanSheetAvailable: Bool + var canOpenURLs = true + /// `true` when resolving a screen's main action list (management / no-active), where restore + /// is always available; `false` when resolving a drilled-in purchase detail screen. + var isScreenLevel = false + var now = Date() +} + +enum ResolvedPathDestination: Equatable { + case restore + case appleManageSheet(subscriptionGroupId: String?) + case webManage(URL) + /// A web-store subscription with no management page configured. There's nowhere to send the + /// customer, so the row explains where to find the link instead of disappearing and leaving + /// them with no way to manage a subscription they're paying for. + case webManageUnavailable + case refund(productId: String) + case changePlan(groupId: String?, productIds: [String]?) + case contactSupport + case url(URL, inApp: Bool) + case custom(String) +} + +struct ResolvedPath: Equatable, Identifiable { + var id: String { path.id } + var path: CustomerCenterConfiguration.Path + var destination: ResolvedPathDestination +} + +extension ResolvedPathDestination { + /// Whether this destination hands the customer off to a web management page — or explains that + /// there isn't one. Surveys are skipped for these: the survey gates an action, and here the + /// action either leaves the app entirely or can't be performed at all. + var isWebManagement: Bool { + switch self { + case .webManage, .webManageUnavailable: return true + default: return false + } + } +} + +enum CustomerCenterPathResolver { + static func resolve( + _ paths: [CustomerCenterConfiguration.Path], + context: PathResolutionContext + ) -> [ResolvedPath] { + paths.compactMap { path in + destination(for: path, context: context).map { ResolvedPath(path: path, destination: $0) } + } + } + + /// Split out of `destination(for:context:)` to keep that switch under the complexity limit. + private static func manageSubscriptionDestination( + context: PathResolutionContext + ) -> ResolvedPathDestination? { + guard let purchase = context.purchase else { return nil } + let sub = purchase.subscription + + if purchase.store == .appStore { + guard + let sub, sub.isActive, sub.willRenew, !sub.isRevoked, + sub.expirationDate != nil, !context.isFamilyShared + else { return nil } + return .appleManageSheet(subscriptionGroupId: sub.subscriptionGroupId ?? context.product?.subscriptionGroupId) + } + + guard [.stripe, .paddle, .superwall].contains(purchase.store) else { return nil } + + // A one-off purchase has nothing to manage, and neither has a subscription that has lapsed or + // been revoked. The App Store branch above gates on both; this one gated on neither, so a + // single Stripe charge and an expired web subscription were each offered a management row. + switch purchase.kind { + case .nonSubscription: return nil + case .subscription(let sub) where sub.isRevoked: return nil + default: break + } + guard purchase.isActive else { return nil } + + // A comped grant — an entitlement with no transaction *and* no store behind it — has nothing + // to manage anywhere, so offer the page only if one exists and never claim a receipt was + // sent. The signal is the nil store, not the missing transaction: a web purchase arrives as a + // bare entitlement whenever the backend sends no matching transaction, and that entitlement + // still carries its store. Keying on the kind alone would take the row back off the paying + // customers it exists for. + if case .entitlementOnly(let entitlement) = purchase.kind, entitlement.store == nil { + return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } + } + return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } ?? .webManageUnavailable + } + + private static func destination( + for path: CustomerCenterConfiguration.Path, + context: PathResolutionContext + ) -> ResolvedPathDestination? { + let purchase = context.purchase + let sub = purchase?.subscription + let isAppStore = purchase?.store == .appStore + let isWebStore = [.stripe, .paddle, .superwall].contains(purchase?.store ?? .other) + + switch path.type { + case .restore: + // Restore is always available at screen level (even when the screen's single-purchase + // layout passes its purchase for the other paths); it's only hidden on drilled-in + // purchase detail screens. + return purchase == nil || context.isScreenLevel ? .restore : nil + + case .contactSupport: + return context.supportEmailAvailable && context.canOpenURLs ? .contactSupport : nil + + case let .url(url, _, method): + guard context.canOpenURLs else { return nil } + let isWeb = ["http", "https"].contains(url.scheme?.lowercased() ?? "") + return .url(url, inApp: method == .inApp && isWeb) + + case .custom(let identifier): + return .custom(identifier) + + case .manageSubscription: + return manageSubscriptionDestination(context: context) + + case .refund(let window): + guard isAppStore, let sub, !sub.isRevoked, sub.offerType != .trial, !context.isFamilyShared else { return nil } + if let price = context.product?.price, price <= 0 { return nil } + if let window, sub.purchaseDate.addingTimeInterval(window) < context.now { return nil } + return .refund(productId: sub.productId) + + case .changePlan(let productIds): + guard + isAppStore, let sub, sub.isActive, !sub.isRevoked, !context.isFamilyShared, + context.isChangePlanSheetAvailable, + purchase?.badge != .lifetime, + context.product?.isAutoRenewable != false + else { return nil } + let groupId = sub.subscriptionGroupId ?? context.product?.subscriptionGroupId + guard groupId != nil else { return nil } + return .changePlan(groupId: groupId, productIds: productIds) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift new file mode 100644 index 0000000000..3ca923488c --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -0,0 +1,236 @@ +// +// PurchasePresentationBuilder.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Builds display-ready `PurchasePresentation` rows from raw `CustomerInfo`. +struct PurchasePresentationBuilder { + var now: () -> Date = Date.init + var strings: CustomerCenterStrings + var dateFormatter: DateFormatter + + init( + now: @escaping () -> Date = Date.init, + strings: CustomerCenterStrings, + locale: Locale = .current, + dateFormatter: DateFormatter? = nil + ) { + self.now = now + self.strings = strings + // Dates must follow the same locale as the strings — `DeviceHelper.preferredLocaleIdentifier` + // via `CustomerCenterEnvironmentProviding.locale` — not the system locale. + self.dateFormatter = dateFormatter ?? { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.locale = locale + return formatter + }() + } + + func build(customerInfo: CustomerInfo, products: [String: ProductDisplayInfo]) -> [PurchasePresentation] { + let entitlementsByProductId = customerInfo.entitlementsByProductId + let subs = subscriptionPresentations( + customerInfo.subscriptions, + products: products, + entitlementsByProductId: entitlementsByProductId + ) + let nonSubs = nonSubscriptionPresentations( + customerInfo.nonSubscriptions, + products: products, + entitlementsByProductId: entitlementsByProductId + ) + let knownProductIds = Set( + customerInfo.subscriptions.map(\.productId) + customerInfo.nonSubscriptions.map(\.productId) + ) + let entitlementOnly = customerInfo.entitlements + .filter { $0.isActive && $0.productIds.isDisjoint(with: knownProductIds) } + .map(entitlementPresentation) + return subs + nonSubs + entitlementOnly + } + + /// The title a purchase falls back to when its product has no display name: the entitlement it + /// unlocks. A purchase is always shown; this only decides what heads the card. With no product + /// name and no entitlement either, the card has no title — never the raw product identifier, + /// which for a Stripe price reads `test:price_1Tu…:no-trial`. + /// + /// A product can grant several entitlements; the lowest identifier is taken so the choice is + /// stable from one render to the next. + static func entitlementTitle(_ entitlements: Set) -> String? { + entitlements.map(\.id).sorted().first + } + + func subscriptionPresentations( + _ subscriptions: [SubscriptionTransaction], + products: [String: ProductDisplayInfo], + entitlementsByProductId: [String: Set] = [:] + ) -> [PurchasePresentation] { + // `CustomerInfo.subscriptions` carries one entry per StoreKit transaction, which includes + // every past renewal of a subscription. Collapse to one row per product: prefer the active + // transaction, otherwise the one with the latest expiration date (nil dates last). + var latestPerProduct: [String: SubscriptionTransaction] = [:] + for sub in subscriptions { + if let existing = latestPerProduct[sub.productId] { + if isPreferred(sub, over: existing) { + latestPerProduct[sub.productId] = sub + } + } else { + latestPerProduct[sub.productId] = sub + } + } + let sorted = latestPerProduct.values.sorted { lhs, rhs in + if lhs.isActive != rhs.isActive { return lhs.isActive } + switch (lhs.expirationDate, rhs.expirationDate) { + case let (lhsDate?, rhsDate?): return lhsDate < rhsDate + case (nil, _?): return false + case (_?, nil): return true + case (nil, nil): return lhs.purchaseDate < rhs.purchaseDate + } + } + return sorted.map { + presentation(for: $0, product: products[$0.productId], entitlements: entitlementsByProductId[$0.productId] ?? []) + } + } + + /// Whether `lhs` better represents its product than `rhs` when both are transactions of the + /// same subscription: active wins, then the latest expiration date (nil dates last), then the + /// latest purchase date. + private func isPreferred(_ lhs: SubscriptionTransaction, over rhs: SubscriptionTransaction) -> Bool { + if lhs.isActive != rhs.isActive { return lhs.isActive } + switch (lhs.expirationDate, rhs.expirationDate) { + case let (lhsDate?, rhsDate?): return lhsDate > rhsDate + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return lhs.purchaseDate > rhs.purchaseDate + } + } + + func nonSubscriptionPresentations( + _ purchases: [NonSubscriptionTransaction], + products: [String: ProductDisplayInfo], + entitlementsByProductId: [String: Set] = [:] + ) -> [PurchasePresentation] { + purchases.sorted { $0.purchaseDate < $1.purchaseDate }.map { purchase in + let product = products[purchase.productId] + // Keyed by transaction id, not product id: consumables can legitimately be purchased + // multiple times, and each purchase gets its own row. + return PurchasePresentation( + id: purchase.transactionId, + kind: .nonSubscription(purchase), + productId: purchase.productId, + title: product?.title ?? Self.entitlementTitle(entitlementsByProductId[purchase.productId] ?? []), + priceLine: product?.localizedPrice, + statusLine: purchase.isRevoked + ? strings.string("customer_center_revoked") + : strings.string("customer_center_purchased_on", dateFormatter.string(from: purchase.purchaseDate)), + badge: purchase.isRevoked ? .revoked : .active, + store: purchase.store, + storeLabelKey: storeLabelKey(purchase.store), + isActive: !purchase.isRevoked, + expirationDate: nil, + purchaseDate: purchase.purchaseDate + ) + } + } + + private func presentation( + for sub: SubscriptionTransaction, + product: ProductDisplayInfo?, + entitlements: Set + ) -> PurchasePresentation { + let badge = badge(for: sub) + let price = product?.localizedPrice + let date = sub.expirationDate.map { dateFormatter.string(from: $0) } + let status: String + switch badge { + case .revoked: status = strings.string("customer_center_revoked") + case .expired: + status = date.map { strings.string("customer_center_expired_on", $0) } + ?? strings.string("customer_center_expired") + case .billingIssue: status = strings.string("customer_center_billing_issue") + case .cancelled: status = date.map { strings.string("customer_center_expires_on", $0) } ?? "" + case .freeTrial: status = date.map { strings.string("customer_center_free_trial_until", $0) } ?? "" + case .lifetime: status = strings.string("customer_center_lifetime") + case .active: + if let date, let price { + status = strings.string("customer_center_renews_on_for", date, price) + } else if let date { + status = strings.string("customer_center_renews_on", date) + } else { + status = "" + } + } + var priceLine: String? + if let price { + if let period = product?.localizedPeriod { + priceLine = strings.string("customer_center_price_per_period", price, period) + } else { + priceLine = price + } + } + return PurchasePresentation( + id: sub.productId, + kind: .subscription(sub), + productId: sub.productId, + title: product?.title ?? Self.entitlementTitle(entitlements), + priceLine: priceLine, + statusLine: status, + badge: badge, + store: sub.store, + storeLabelKey: storeLabelKey(sub.store), + isActive: sub.isActive, + expirationDate: sub.expirationDate, + purchaseDate: sub.purchaseDate + ) + } + + private func entitlementPresentation(_ entitlement: Entitlement) -> PurchasePresentation { + let isLifetime = entitlement.isLifetime == true + let date = entitlement.expiresAt.map { dateFormatter.string(from: $0) } + let status: String + if isLifetime { + status = strings.string("customer_center_lifetime") + } else if let date { + status = strings.string("customer_center_expires_on", date) + } else { + status = strings.string("customer_center_active_via_superwall") + } + return PurchasePresentation( + id: "entitlement:\(entitlement.id)", + kind: .entitlementOnly(entitlement), + productId: entitlement.latestProductId, + title: entitlement.id, + priceLine: nil, + statusLine: status, + badge: isLifetime ? .lifetime : .active, + store: entitlement.store ?? .superwall, + storeLabelKey: storeLabelKey(entitlement.store ?? .superwall), + isActive: entitlement.isActive, + expirationDate: entitlement.expiresAt, + purchaseDate: entitlement.startsAt + ) + } + + func badge(for sub: SubscriptionTransaction) -> PurchaseBadge { + if sub.isRevoked { return .revoked } + if !sub.isActive { return .expired } + if sub.isInGracePeriod || sub.isInBillingRetryPeriod { return .billingIssue } + if !sub.willRenew { return .cancelled } + if sub.offerType == .trial { return .freeTrial } + return .active + } + + func storeLabelKey(_ store: ProductStore) -> String? { + switch store { + case .appStore: return nil + case .stripe, .paddle: return "customer_center_store_web" + case .playStore: return "customer_center_store_google_play" + case .superwall: return "customer_center_store_superwall" + case .other, .custom: return "customer_center_store_other" + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift new file mode 100644 index 0000000000..ec2c3aae2e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift @@ -0,0 +1,57 @@ +// +// SupportEmailComposer.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +struct SupportEmailDiagnostics: Equatable { + var userId: String + var appVersion: String + var osVersion: String + var deviceModel: String + var sdkVersion: String + var activeEntitlementIds: [String] + var isSandbox: Bool +} + +enum SupportEmailComposer { + static func mailtoURL( + email: String?, + subject: String, + body: String, + diagnostics: SupportEmailDiagnostics + ) -> URL? { + guard + let email = email?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty + else { + return nil + } + let entitlements = diagnostics.activeEntitlementIds.isEmpty + ? "none" + : diagnostics.activeEntitlementIds.joined(separator: ", ") + let fullBody = """ + \(body) + + --------------------------- + - User ID: \(diagnostics.userId) + - App Version: \(diagnostics.appVersion) + - OS Version: \(diagnostics.osVersion) + - Device: \(diagnostics.deviceModel) + - SDK Version: \(diagnostics.sdkVersion) + - Entitlements: \(entitlements) + - Sandbox: \(diagnostics.isSandbox) + """ + var components = URLComponents() + components.scheme = "mailto" + components.path = email + components.queryItems = [ + URLQueryItem(name: "subject", value: subject), + URLQueryItem(name: "body", value: fullBody) + ] + return components.url + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift new file mode 100644 index 0000000000..0cc6815d09 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift @@ -0,0 +1,92 @@ +// +// CustomerCenterAction.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// An action the user selected in the Customer Center. +public enum CustomerCenterAction: Equatable, Sendable { + case restore + case manageSubscription + case refund + case changePlan + case contactSupport + case url(URL) + case custom(identifier: String) + + init(pathType: CustomerCenterConfiguration.PathType) { + switch pathType { + case .restore: self = .restore + case .manageSubscription: self = .manageSubscription + case .refund: self = .refund + case .changePlan: self = .changePlan + case .contactSupport: self = .contactSupport + case .url(let url, _, _): self = .url(url) + case .custom(let identifier): self = .custom(identifier: identifier) + } + } + + /// Snake-case name used in events. + var analyticsName: String { + switch self { + case .restore: return "restore" + case .manageSubscription: return "manage_subscription" + case .refund: return "refund" + case .changePlan: return "change_plan" + case .contactSupport: return "contact_support" + case .url: return "url" + case .custom: return "custom" + } + } +} + +/// Objective-C representation of ``CustomerCenterAction``. +@objc(SWKCustomerCenterActionType) +public enum CustomerCenterActionTypeObjc: Int { + case restore + case manageSubscription + case refund + case changePlan + case contactSupport + case url + case custom +} + +@objc(SWKCustomerCenterAction) +@objcMembers +public final class CustomerCenterActionObjc: NSObject { + public let type: CustomerCenterActionTypeObjc + public let url: URL? + public let customIdentifier: String? + + init(_ action: CustomerCenterAction) { + switch action { + case .restore: type = .restore; url = nil; customIdentifier = nil + case .manageSubscription: type = .manageSubscription; url = nil; customIdentifier = nil + case .refund: type = .refund; url = nil; customIdentifier = nil + case .changePlan: type = .changePlan; url = nil; customIdentifier = nil + case .contactSupport: type = .contactSupport; url = nil; customIdentifier = nil + case .url(let value): type = .url; url = value; customIdentifier = nil + case .custom(let identifier): type = .custom; url = nil; customIdentifier = identifier + } + } +} + +/// Outcome of a refund request made from the Customer Center. +@objc(SWKCustomerCenterRefundStatus) +public enum CustomerCenterRefundStatus: Int, Sendable { + case success + case userCancelled + case error + + var analyticsName: String { + switch self { + case .success: return "success" + case .userCancelled: return "user_cancelled" + case .error: return "error" + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift new file mode 100644 index 0000000000..56113ea570 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift @@ -0,0 +1,85 @@ +// +// CustomerCenterConfiguration+Appearance.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Foundation +import UIKit + +extension CustomerCenterConfiguration { + // MARK: - Appearance + + /// Colour overrides for the Customer Center. + /// + /// Only the accent is applied. Slots for background, text and button colours existed here + /// before anything read them; exposing colours that reach no pixel would have meant either + /// honouring them later or removing them later, and removing them from a public, `Codable`, + /// Objective-C-exposed type is a breaking change. They come back when they're wired up. + @objc(SWKCustomerCenterAppearance) + @objcMembers + public final class Appearance: NSObject, Codable { + /// Tints controls and links. `nil` uses the system accent. + public var accent: ColorPair? + + public init(accent: ColorPair? = nil) { + self.accent = accent + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Appearance else { return false } + return accent == other.accent + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(accent) + return hasher.finalize() + } + + /// A light/dark color pair stored as hex strings (`#RRGGBB` or `#RRGGBBAA`). + @objc(SWKCustomerCenterColorPair) + @objcMembers + public final class ColorPair: NSObject, Codable { + public var light: String + public var dark: String + + public init(light: String, dark: String) { + self.light = light + self.dark = dark + } + + @nonobjc public convenience init(light: UIColor, dark: UIColor) { + self.init(light: light.hexString, dark: dark.hexString) + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? ColorPair else { return false } + return light == other.light && dark == other.dark + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(light) + hasher.combine(dark) + return hasher.finalize() + } + } + } +} + +extension UIColor { + /// `#RRGGBBAA` representation. + var hexString: String { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return String( + format: "#%02X%02X%02X%02X", + Int(round(red * 255)), + Int(round(green * 255)), + Int(round(blue * 255)), + Int(round(alpha * 255)) + ) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift new file mode 100644 index 0000000000..5129198b99 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift @@ -0,0 +1,83 @@ +// +// CustomerCenterConfiguration+ObjC.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Objective-C mirror of ``CustomerCenterConfiguration/PathType``. +@objc(SWKCustomerCenterPathType) +public enum CustomerCenterPathTypeObjc: Int { + case restore, manageSubscription, refund, changePlan, contactSupport, url, custom +} + +@objc(SWKCustomerCenterOpenMethod) +public enum CustomerCenterOpenMethodObjc: Int { + case inApp, external +} + +extension CustomerCenterConfiguration.Path { + /// The path's type, for Objective-C. + @objc public var pathType: CustomerCenterPathTypeObjc { + switch type { + case .restore: return .restore + case .manageSubscription: return .manageSubscription + case .refund: return .refund + case .changePlan: return .changePlan + case .contactSupport: return .contactSupport + case .url: return .url + case .custom: return .custom + } + } + @objc public var url: URL? { + if case .url(let url, _, _) = type { return url } + return nil + } + @objc public var openMethodObjc: CustomerCenterOpenMethodObjc { + if case .url(_, _, let method) = type, method == .external { return .external } + return .inApp + } + @objc public var customIdentifier: String? { + if case .custom(let id) = type { return id } + return nil + } + @objc public var refundWindow: NSNumber? { + if case .refund(let window) = type, let window { return NSNumber(value: window) } + return nil + } + @objc public var changePlanProductIds: [String]? { + if case .changePlan(let ids) = type { return ids } + return nil + } + + @objc public static func restore(id: String, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .restore, title: title) + } + @objc public static func manageSubscription(id: String, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .manageSubscription, title: title) + } + @objc public static func refund(id: String, window: NSNumber?, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .refund(window: window?.doubleValue), title: title) + } + @objc public static func changePlan(id: String, productIds: [String]?, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .changePlan(productIds: productIds), title: title) + } + @objc public static func contactSupport(id: String, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .contactSupport, title: title) + } + /// `title` is non-optional here, unlike the other path factories: a URL row has no default + /// name to fall back on. It is set on the path as well as in the case, because `type` is + /// `@nonobjc` — without this, an Objective-C caller could hand a title in and never read it back. + @objc public static func url(id: String, url: URL, openMethod: CustomerCenterOpenMethodObjc, title: String) -> CustomerCenterConfiguration.Path { + .init( + id: id, + type: .url(url, title: title, openMethod: openMethod == .external ? .external : .inApp), + title: title + ) + } + @objc public static func custom(id: String, identifier: String, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .custom(identifier: identifier), title: title) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift new file mode 100644 index 0000000000..470c37887e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -0,0 +1,321 @@ +// +// CustomerCenterConfiguration.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation +import UIKit + +/// Configures the screens, actions, support options and appearance of the Customer Center. +/// +/// Set the default via ``SuperwallOptions/customerCenter`` before calling `configure`, or pass one to +/// ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``. +@objc(SWKCustomerCenterConfiguration) +@objcMembers +public final class CustomerCenterConfiguration: NSObject, Codable { + /// The screen shown when the user has at least one subscription (active or expired) or purchase. + public var managementScreen: Screen + /// The screen shown when the user has no purchases at all. + public var noPurchasesScreen: Screen + /// Support-related settings (email, app update warning, web management URL). + public var support: Support + /// Optional color overrides. `nil` values use system colors. + public var appearance: Appearance + /// Shows the account details section (user ID, original download date). Defaults to `true`. + public var showsAccountDetails: Bool + /// Warns when both an App Store and a web subscription are active. Defaults to `true`. + public var warnsAboutDuplicateSubscriptions: Bool + + public init( + managementScreen: Screen, + noPurchasesScreen: Screen, + support: Support = Support(), + appearance: Appearance = Appearance(), + showsAccountDetails: Bool = true, + warnsAboutDuplicateSubscriptions: Bool = true + ) { + self.managementScreen = managementScreen + self.noPurchasesScreen = noPurchasesScreen + self.support = support + self.appearance = appearance + self.showsAccountDetails = showsAccountDetails + self.warnsAboutDuplicateSubscriptions = warnsAboutDuplicateSubscriptions + } + + /// A fresh copy of the default configuration: restore, change plan, refund, manage subscription + /// (with a cancellation survey) and contact support on the management screen; restore on the + /// no-purchases screen. + public static var `default`: CustomerCenterConfiguration { + let cancelSurvey = FeedbackSurvey( + id: "cancel_survey", + title: nil, + options: [ + .init(id: "too_expensive", title: nil), + .init(id: "dont_use", title: nil), + .init(id: "bought_by_mistake", title: nil) + ] + ) + return CustomerCenterConfiguration( + managementScreen: Screen( + title: nil, + subtitle: nil, + paths: [ + Path(id: "restore", type: .restore), + Path(id: "change_plan", type: .changePlan()), + Path(id: "refund", type: .refund()), + Path(id: "manage_subscription", type: .manageSubscription, survey: cancelSurvey), + Path(id: "contact_support", type: .contactSupport) + ] + ), + noPurchasesScreen: Screen( + title: nil, + subtitle: nil, + paths: [Path(id: "restore", type: .restore)] + ) + ) + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? CustomerCenterConfiguration else { return false } + return managementScreen == other.managementScreen + && noPurchasesScreen == other.noPurchasesScreen + && support == other.support + && appearance == other.appearance + && showsAccountDetails == other.showsAccountDetails + && warnsAboutDuplicateSubscriptions == other.warnsAboutDuplicateSubscriptions + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(managementScreen) + hasher.combine(noPurchasesScreen) + hasher.combine(support) + hasher.combine(appearance) + hasher.combine(showsAccountDetails) + hasher.combine(warnsAboutDuplicateSubscriptions) + return hasher.finalize() + } + + // MARK: - Screen + + /// A Customer Center screen: a title, optional subtitle and an ordered list of paths. + @objc(SWKCustomerCenterScreen) + @objcMembers + public final class Screen: NSObject, Codable { + /// Title. `nil` uses the localized default for the screen. + public var title: String? + /// Subtitle. `nil` uses the localized default (no-purchases screen) or none (management screen). + public var subtitle: String? + /// Ordered paths (actions) shown on the screen. + public var paths: [Path] + + public init(title: String? = nil, subtitle: String? = nil, paths: [Path]) { + self.title = title + self.subtitle = subtitle + self.paths = paths + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Screen else { return false } + return title == other.title && subtitle == other.subtitle && paths == other.paths + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(title) + hasher.combine(subtitle) + hasher.combine(paths) + return hasher.finalize() + } + } + + // MARK: - Path + + /// An action row in the Customer Center. + @objc(SWKCustomerCenterPath) + @objcMembers + public final class Path: NSObject, Codable, Identifiable { + /// Stable identifier, reported in events and delegate callbacks. + public var id: String + /// What the path does. + @nonobjc public var type: PathType + /// Row title. `nil` uses the localized default for `type`. + public var title: String? + /// Optional survey shown before the action runs. + public var survey: FeedbackSurvey? + + @nonobjc public init(id: String, type: PathType, title: String? = nil, survey: FeedbackSurvey? = nil) { + self.id = id + self.type = type + self.title = title + self.survey = survey + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Path else { return false } + return id == other.id && type == other.type && title == other.title && survey == other.survey + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(id) + hasher.combine(type) + hasher.combine(title) + hasher.combine(survey) + return hasher.finalize() + } + } + + /// The kinds of path the Customer Center supports. + public enum PathType: Codable, Hashable { + case restore + case manageSubscription + /// `window`: optional seconds since purchase during which a refund may be requested. + case refund(window: TimeInterval? = nil) + /// `productIds`: optional subset of the subscription group to offer. `nil` offers the whole group. + case changePlan(productIds: [String]? = nil) + case contactSupport + /// `title`: what the row says. Required, because it is the one path type the SDK cannot name + /// for you — every other type has a fixed meaning and a localized default, while a URL could + /// be anything. Deriving a label from the URL doesn't work: the host is identical across an + /// app's own links, so FAQ, terms and privacy would all render as one repeated row. + case url(URL, title: String, openMethod: OpenMethod) + case custom(identifier: String) + } + + /// How a URL path opens. + public enum OpenMethod: String, Codable, Hashable { + case inApp + case external + } + + // MARK: - FeedbackSurvey + + /// A single-choice survey shown before a path's action runs. + @objc(SWKCustomerCenterFeedbackSurvey) + @objcMembers + public final class FeedbackSurvey: NSObject, Codable { + public var id: String + /// Question text. `nil` uses the localized default ("Why are you cancelling?"). + public var title: String? + public var options: [Option] + + public init(id: String, title: String?, options: [Option]) { + self.id = id + self.title = title + self.options = options + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? FeedbackSurvey else { return false } + return id == other.id && title == other.title && options == other.options + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(id) + hasher.combine(title) + hasher.combine(options) + return hasher.finalize() + } + + @objc(SWKCustomerCenterFeedbackSurveyOption) + @objcMembers + public final class Option: NSObject, Codable { + public var id: String + /// Option text. `nil` uses the localized default when `id` is one of the built-in ids. + public var title: String? + + public init(id: String, title: String?) { + self.id = id + self.title = title + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Option else { return false } + return id == other.id && title == other.title + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(id) + hasher.combine(title) + return hasher.finalize() + } + } + } + + // MARK: - Support + + @objc(SWKCustomerCenterSupport) + @objcMembers + public final class Support: NSObject, Codable { + /// Support email for the "Contact support" path. `nil` hides that path. + public var email: String? + /// Latest published app version. When set and newer than the installed version, an update banner shows. + public var latestAppVersion: String? + /// Whether to show the update banner. Defaults to `true`. + public var shouldWarnToUpdate: Bool + /// Whether to look the latest published version up from the App Store when + /// ``latestAppVersion`` isn't set. Defaults to `true`. + /// + /// The lookup is skipped entirely on TestFlight, sandbox and simulator builds, whose version + /// is normally *ahead* of the App Store — warning those users to "update" would send them to + /// an older build. It is also skipped when ``latestAppVersion`` is set, which always wins. + public var checksAppStoreForUpdates: Bool + /// Overrides the web subscription management page URL used for web-store subscriptions. + public var webManagementURL: URL? + + public init( + email: String? = nil, + latestAppVersion: String? = nil, + shouldWarnToUpdate: Bool = true, + checksAppStoreForUpdates: Bool = true, + webManagementURL: URL? = nil + ) { + self.email = email + self.latestAppVersion = latestAppVersion + self.shouldWarnToUpdate = shouldWarnToUpdate + self.checksAppStoreForUpdates = checksAppStoreForUpdates + self.webManagementURL = webManagementURL + } + + private enum CodingKeys: String, CodingKey { + case email, latestAppVersion, shouldWarnToUpdate, checksAppStoreForUpdates, webManagementURL + } + + /// Hand-written so that `checksAppStoreForUpdates` can default when absent. Everything the + /// dashboard will eventually serve has to survive being decoded from JSON written before the + /// key existed; the synthesised decoder would throw instead. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + email = try container.decodeIfPresent(String.self, forKey: .email) + latestAppVersion = try container.decodeIfPresent(String.self, forKey: .latestAppVersion) + shouldWarnToUpdate = try container.decodeIfPresent(Bool.self, forKey: .shouldWarnToUpdate) ?? true + checksAppStoreForUpdates = try container.decodeIfPresent(Bool.self, forKey: .checksAppStoreForUpdates) ?? true + webManagementURL = try container.decodeIfPresent(URL.self, forKey: .webManagementURL) + super.init() + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Support else { return false } + return email == other.email + && latestAppVersion == other.latestAppVersion + && shouldWarnToUpdate == other.shouldWarnToUpdate + && checksAppStoreForUpdates == other.checksAppStoreForUpdates + && webManagementURL == other.webManagementURL + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(email) + hasher.combine(latestAppVersion) + hasher.combine(shouldWarnToUpdate) + hasher.combine(checksAppStoreForUpdates) + hasher.combine(webManagementURL) + return hasher.finalize() + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift new file mode 100644 index 0000000000..236093c630 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift @@ -0,0 +1,42 @@ +// +// CustomerCenterScreenState.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +enum CustomerCenterScreenState: Equatable { case loading, management, noPurchases } +enum CustomerCenterRestoreState: Equatable { case idle, restoring, restored, notFound } + +enum CustomerCenterSheet: Identifiable, Equatable { + case survey(pathId: String) + case manageSubscriptions(groupId: String?) + case changePlan(groupId: String?, productIds: [String]?) + case refund(transactionId: UInt64, productId: String) + case safari(URL) + case noMailApp(email: String) + case webManageUnavailable + + var id: String { + switch self { + case .survey(let id): return "survey:\(id)" + case .manageSubscriptions(let groupId): return "manage:\(groupId ?? "")" + case let .changePlan(groupId, productIds): + return "change:\(groupId ?? ""):\(productIds?.joined(separator: ",") ?? "")" + case .refund(let transactionId, _): return "refund:\(transactionId)" + case .safari(let url): return "safari:\(url.absoluteString)" + case .noMailApp: return "nomail" + case .webManageUnavailable: return "webManageUnavailable" + } + } +} + +struct CustomerCenterCallbacks { + var shouldRestore: ((@escaping (Bool) -> Void) -> Void)? + var didSelectAction: ((CustomerCenterAction, SubscriptionTransaction?) -> Void)? + var didCompleteSurvey: ((_ surveyId: String, _ optionId: String, _ action: CustomerCenterAction) -> Void)? + var didCompleteRefund: ((_ productId: String, _ status: CustomerCenterRefundStatus) -> Void)? + var didDismiss: (() -> Void)? +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift new file mode 100644 index 0000000000..c04fd938eb --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift @@ -0,0 +1,65 @@ +// +// PurchasePresentation.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Display-oriented product info, decoupled from `StoreProduct` for testability. +struct ProductDisplayInfo: Equatable { + var productId: String + /// The product's display name, or `nil` when neither StoreKit nor the catalogue supplied one. + /// Never the identifier standing in for one: a card without a name shows no name. + var title: String? + var localizedPrice: String? + var price: Decimal? + var localizedPeriod: String? + var subscriptionGroupId: String? + var isAutoRenewable: Bool? +} + +enum PurchaseBadge: Equatable { + case lifetime, revoked, expired, billingIssue, cancelled, freeTrial, active +} + +enum PurchaseKind: Equatable { + case subscription(SubscriptionTransaction) + case nonSubscription(NonSubscriptionTransaction) + case entitlementOnly(Entitlement) +} + +struct PurchasePresentation: Identifiable, Equatable { + var id: String + var kind: PurchaseKind + var productId: String? + /// What the card is headed with: the product's display name, else the entitlement it unlocks, + /// else nothing. The purchase is always shown; only this label is allowed to be absent. + var title: String? + var priceLine: String? + var statusLine: String + var badge: PurchaseBadge + var store: ProductStore + var storeLabelKey: String? + var isActive: Bool + var expirationDate: Date? + var purchaseDate: Date? + + var subscription: SubscriptionTransaction? { + if case .subscription(let sub) = kind { return sub } + return nil + } + + /// Whether the row opens a detail screen — which is where a purchase's own actions live. + /// + /// A subscription does. So does an entitlement-only purchase: a web subscription arrives as a + /// bare entitlement whenever the backend sends no matching transaction, and that customer still + /// has a management page to reach. Splitting on `subscription != nil` alone stranded them — + /// the only row that could carry the management link had nowhere to open. A one-off purchase + /// has no action of its own, so it stays a plain card. + var opensDetail: Bool { + if case .nonSubscription = kind { return false } + return true + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift new file mode 100644 index 0000000000..52b882781c --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift @@ -0,0 +1,98 @@ +// +// View+CustomerCenter.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +private struct CustomerCenterCallbacksKey: EnvironmentKey { + static let defaultValue = CustomerCenterCallbacksBox() +} + +/// Reference box so modifiers can accumulate callbacks down the view tree. +@available(iOS 15.0, *) +final class CustomerCenterCallbacksBox { + var callbacks = CustomerCenterCallbacks() +} + +@available(iOS 15.0, *) +extension EnvironmentValues { + var customerCenterCallbacks: CustomerCenterCallbacksBox { + get { self[CustomerCenterCallbacksKey.self] } + set { self[CustomerCenterCallbacksKey.self] = newValue } + } +} + +@available(iOS 15.0, *) +public extension View { + /// Presents the Customer Center as a sheet. + /// + /// The name is deliberately Superwall-specific. Other subscription SDKs put a + /// `presentCustomerCenter` modifier on `View` too, and because every parameter after + /// `isPresented` is defaulted on both sides, a shared name would make the common call forms + /// ambiguous — a compile error — in any file that imports both. Extension methods can't be + /// module-qualified at the call site, so the name has to do the disambiguating. + /// + /// - Parameters: + /// - isPresented: Controls presentation, same as the standard `sheet` modifier. + /// - configuration: Overrides ``SuperwallOptions/customerCenter``. `nil` uses the options value. + /// - onDismiss: Called after the sheet is dismissed. + func presentSuperwallCustomerCenter( + isPresented: Binding, + configuration: CustomerCenterConfiguration? = nil, + onDismiss: (() -> Void)? = nil + ) -> some View { + sheet(isPresented: isPresented, onDismiss: onDismiss) { + CustomerCenterView(configuration: configuration) + } + } + + /// Gate restores (e.g. require authentication). Call `resume(true)` to continue, `resume(false)` to cancel. + func onCustomerCenterShouldRestore( + _ handler: @escaping (_ resume: @escaping (Bool) -> Void) -> Void + ) -> some View { + modifier(CustomerCenterCallbackModifier { $0.shouldRestore = handler }) + } + + /// Called when the user selects an action in the Customer Center, with the purchase it applies to, if any. + func onCustomerCenterAction( + _ handler: @escaping (CustomerCenterAction, SubscriptionTransaction?) -> Void + ) -> some View { + modifier(CustomerCenterCallbackModifier { $0.didSelectAction = handler }) + } + + /// Called when the user answers a feedback survey, before the associated action is performed. + func onCustomerCenterSurveyResponse( + _ handler: @escaping (_ surveyId: String, _ optionId: String, _ action: CustomerCenterAction) -> Void + ) -> some View { + modifier(CustomerCenterCallbackModifier { $0.didCompleteSurvey = handler }) + } + + /// Called when a refund request finishes, with its outcome. + func onCustomerCenterRefundRequest( + _ handler: @escaping (_ productId: String, _ status: CustomerCenterRefundStatus) -> Void + ) -> some View { + modifier(CustomerCenterCallbackModifier { $0.didCompleteRefund = handler }) + } + + /// Called when the Customer Center is dismissed. + func onCustomerCenterDismiss(_ handler: @escaping () -> Void) -> some View { + modifier(CustomerCenterCallbackModifier { $0.didDismiss = handler }) + } +} + +@available(iOS 15.0, *) +private struct CustomerCenterCallbackModifier: ViewModifier { + let update: (inout CustomerCenterCallbacks) -> Void + @Environment(\.customerCenterCallbacks) private var box + + func body(content: Content) -> some View { + let newBox = CustomerCenterCallbacksBox() + newBox.callbacks = box.callbacks + update(&newBox.callbacks) + return content.environment(\.customerCenterCallbacks, newBox) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift new file mode 100644 index 0000000000..783c2bf05b --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift @@ -0,0 +1,64 @@ +// +// CustomerCenterDelegateAdapter.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// An adapter between the internal SDK and the public swift/objective-c ``CustomerCenterDelegate``. +@available(iOS 15.0, *) +struct CustomerCenterDelegateAdapter { + // Weak so the view controller never retains its delegate — a host that both presents the + // Customer Center and is its own delegate would otherwise cycle with the VC/view model that + // holds these callbacks. Callers that create a delegate inline must keep their own strong + // reference; `Superwall.shared.presentCustomerCenter(delegate:)` is expected to provide that + // strong retention for the duration of the presentation. + weak var swiftDelegate: CustomerCenterDelegate? + weak var objcDelegate: CustomerCenterDelegateObjc? + + /// Builds the callbacks the view model uses to notify the delegate. + /// + /// `shouldRestore` is left `nil` unless a Swift delegate is set or the ObjC delegate implements + /// the optional method, so the view model's default (proceed) behavior applies when there's + /// nothing to gate on. + func makeCallbacks() -> CustomerCenterCallbacks { + var callbacks = CustomerCenterCallbacks() + let objcImplementsShouldRestore = (objcDelegate as? NSObjectProtocol)?.responds( + to: #selector(CustomerCenterDelegateObjc.customerCenter(shouldRestorePurchases:)) + ) ?? false + if swiftDelegate != nil || objcImplementsShouldRestore { + callbacks.shouldRestore = { [weak swiftDelegate, weak objcDelegate] resume in + if let swiftDelegate { + swiftDelegate.customerCenter(shouldRestorePurchases: resume) + } else if let objcDelegate { + objcDelegate.customerCenter?(shouldRestorePurchases: resume) + } else { + resume(true) + } + } + } + callbacks.didSelectAction = { [weak swiftDelegate, weak objcDelegate] action, purchase in + swiftDelegate?.customerCenter(didSelect: action, for: purchase) + objcDelegate?.customerCenter?(didSelect: CustomerCenterActionObjc(action), for: purchase) + } + callbacks.didCompleteSurvey = { [weak swiftDelegate, weak objcDelegate] surveyId, optionId, action in + swiftDelegate?.customerCenter(didCompleteSurvey: surveyId, optionId: optionId, for: action) + objcDelegate?.customerCenter?( + didCompleteSurvey: surveyId, + optionId: optionId, + for: CustomerCenterActionObjc(action) + ) + } + callbacks.didCompleteRefund = { [weak swiftDelegate, weak objcDelegate] productId, status in + swiftDelegate?.customerCenter(didCompleteRefundRequestFor: productId, status: status) + objcDelegate?.customerCenter?(didCompleteRefundRequestFor: productId, status: status) + } + callbacks.didDismiss = { [weak swiftDelegate, weak objcDelegate] in + swiftDelegate?.customerCenterDidDismiss() + objcDelegate?.customerCenterDidDismiss?() + } + return callbacks + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift new file mode 100644 index 0000000000..fd4476eb7e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -0,0 +1,122 @@ +// +// CustomerCenterPushNavigator.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import SwiftUI +import UIKit + +/// Pushes the Customer Center's drill-downs onto the host's own navigation controller. +/// +/// Used only by ``CustomerCenterPresentationStyle/pushed``, where there's no SwiftUI navigation +/// ancestor for `NavigationLink` to use. Each destination becomes its own hosting controller, so +/// the host's navigation bar drives it — their back button, their title treatment, their +/// appearance — and nothing of theirs is modified. +@available(iOS 15.0, *) +@MainActor +final class CustomerCenterPushNavigator: CustomerCenterNavigating { + weak var presenter: UIViewController? + private let viewModel: CustomerCenterViewModel + + init(viewModel: CustomerCenterViewModel) { + self.viewModel = viewModel + } + + func push(_ destination: Destination) { + guard let navigationController = presenter?.navigationController else { return } + + // A pushed destination is a fresh SwiftUI root: nothing from the presenting hierarchy's + // environment reaches it, so the Customer Center's own values have to be reapplied. + let depth = viewModel.pushDepth + 1 + // The theme is resolved inside the destination rather than captured here, so it follows the + // colour scheme. Reading `traitCollection` at push time would freeze a pushed screen in + // whichever appearance was active when it opened, while the root kept tracking the change. + let hosted = CustomerCenterThemedContainer( + appearance: viewModel.configuration.appearance, + content: destination.customerCenterSheets(viewModel: viewModel, surfaceDepth: depth) + ) + .environment(\.customerCenterStrings, viewModel.strings) + .environment(\.customerCenterNavigator, self) + + let controller = CustomerCenterPushedHostingController(rootView: hosted) + controller.onRemovedFromParent = { [weak self] in + // Back to whatever is underneath. `min` rather than a plain assignment because popping + // several screens at once removes them all and UIKit doesn't document the order it calls + // `didMove(toParent:)` in: taking the lowest reported depth is the same answer whichever + // way round they arrive, where assigning leaves the depth stranded above the surface the + // user is actually on — and that surface then can't present anything for the rest of the + // presentation. + guard let self else { return } + self.viewModel.pushDepth = min(self.viewModel.pushDepth, depth - 1) + } + controller.onCoveredWhileStillInStack = { [weak self] in + self?.viewModel.suppressDismissalUntilNextAppearance() + } + viewModel.pushDepth = depth + navigationController.pushViewController(controller, animated: true) + } +} + +/// Applies the Customer Center's theme to a pushed screen, recomputing it whenever the colour +/// scheme changes. A pushed destination is its own SwiftUI root, so it inherits nothing from the +/// screen that pushed it. +@available(iOS 15.0, *) +private struct CustomerCenterThemedContainer: View { + let appearance: CustomerCenterConfiguration.Appearance + let content: Content + @Environment(\.colorScheme) private var colorScheme + + private var theme: CustomerCenterTheme { + CustomerCenterTheme(appearance: appearance, colorScheme: colorScheme) + } + + var body: some View { + content + .environment(\.customerCenterTheme, theme) + .tint(theme.accent) + } +} + +/// A hosting controller that reports being popped, so the navigator can restore the depth, and +/// vetoes the dismissal debounce when it is merely covered. +@available(iOS 15.0, *) +private final class CustomerCenterPushedHostingController: UIHostingController { + var onRemovedFromParent: (() -> Void)? + var onCoveredWhileStillInStack: (() -> Void)? + private var hasReportedRemoval = false + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // The whole chain, not just `self`: when the host dismisses a navigation controller that holds + // the Customer Center, UIKit marks the container rather than this screen, and treating that as + // a cover would veto the dismissal for a Customer Center that is genuinely gone. + if isLeavingHierarchyOrAContainerIs { + reportRemoval() + return + } + // Merely covered — the host pushed its own screen on top, presented something, or the user + // switched tabs. `super` has just forwarded the disappearance into SwiftUI, whose + // `onDisappear` dropped the visible-surface count to zero and armed the dismissal debounce. + // Left to fire it would deliver `customerCenterDidDismiss()` and latch, silencing the real + // teardown later. Same veto the root controller performs, one level down. + onCoveredWhileStillInStack?() + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + // Not redundant with the above: a controller that was already covered has disappeared once + // already, so being popped from under the covering screen produces no second disappearance. + if parent == nil { + reportRemoval() + } + } + + /// Latched, because an ordinary pop is both a disappearance and a removal from the container. + private func reportRemoval() { + guard !hasReportedRemoval else { return } + hasReportedRemoval = true + onRemovedFromParent?() + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift new file mode 100644 index 0000000000..d03a805aa2 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -0,0 +1,235 @@ +// +// CustomerCenterViewController.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI +import UIKit + +/// How a ``CustomerCenterViewController`` is put on screen. +@objc(SWKCustomerCenterPresentationStyle) +public enum CustomerCenterPresentationStyle: Int { + /// Presented modally, with `present(_:animated:)`. Shows a close button that dismisses it. + case modal + + /// Pushed onto a `UINavigationController` you own. Renders into your navigation bar and leaves + /// it entirely alone — your title, your back button, your appearance. Its own drill-downs are + /// pushed onto your stack as further view controllers. + case pushed +} + +extension CustomerCenterPresentationStyle { + /// The `presentation` parameter reported on Customer Center events. + var analyticsValue: String { + switch self { + case .modal: return "sheet" + case .pushed: return "pushed" + } + } +} + +/// A UIKit container for ``CustomerCenterView``. +/// +/// Present it modally, or push it onto a navigation controller of your own with +/// ``CustomerCenterPresentationStyle/pushed``. +@available(iOS 15.0, *) +@objc(SWKCustomerCenterViewController) +public final class CustomerCenterViewController: UIHostingController { + let viewModel: CustomerCenterViewModel + let presentationStyle: CustomerCenterPresentationStyle + var onDismiss: (() -> Void)? + + /// Retains the navigator that pushes this controller's own drill-downs, in `.pushed` style. + private var pushNavigator: CustomerCenterPushNavigator? + + /// Whether this controller was on screen as part of a modal presentation, recorded while it + /// still is. Compared against `presentingViewController` on the way out — see + /// ``isLeavingHierarchy``. + /// + /// Internal rather than private only so tests can set it: a hostless test target never drives a + /// modal transition to completion, so UIKit never populates `presentingViewController` there and + /// this can't be reached through a real presentation. + var wasPresentedModally = false + + /// Latches ``deliverDismissal()``; see its note on the overlapping call sites. + private var hasDeliveredDismissal = false + + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter``; `nil` uses the options value. + /// - presentationStyle: Whether you present this controller modally or push it onto a + /// navigation controller of your own. Defaults to ``CustomerCenterPresentationStyle/modal``. + /// - delegate: Receives Customer Center events. The view controller does not retain its + /// delegate. Keep a strong reference to it for the duration of the presentation — or present + /// via `Superwall.shared.presentCustomerCenter(delegate:)`, which retains the delegate while + /// the Customer Center is presented. + public convenience init( + configuration: CustomerCenterConfiguration? = nil, + presentationStyle: CustomerCenterPresentationStyle = .modal, + delegate: CustomerCenterDelegate? = nil + ) { + self.init( + viewModel: CustomerCenterManager.makeViewModel(configuration: configuration), + adapter: CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil), + presentationStyle: presentationStyle + ) + } + + /// Objective-C initializer. + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter``; `nil` uses the options value. + /// - presentationStyle: Whether you present this controller modally or push it onto a + /// navigation controller of your own. + /// - objcDelegate: Receives Customer Center events. The view controller does not retain its + /// delegate. Keep a strong reference to it for the duration of the presentation — or present + /// via `Superwall.shared.presentCustomerCenter(delegate:)`, which retains the delegate while + /// the Customer Center is presented. + @available(swift, obsoleted: 1.0) + @objc(initWithConfiguration:presentationStyle:delegate:) + public convenience init( + configuration: CustomerCenterConfiguration?, + presentationStyle: CustomerCenterPresentationStyle, + objcDelegate: CustomerCenterDelegateObjc? + ) { + self.init( + viewModel: CustomerCenterManager.makeViewModel(configuration: configuration), + adapter: CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: objcDelegate), + presentationStyle: presentationStyle + ) + } + + init( + viewModel: CustomerCenterViewModel, + adapter: CustomerCenterDelegateAdapter, + presentationStyle: CustomerCenterPresentationStyle + ) { + self.viewModel = viewModel + self.presentationStyle = presentationStyle + viewModel.callbacks = adapter.makeCallbacks() + viewModel.presentationMode = presentationStyle.analyticsValue + + // Presented modally we supply the navigation; pushed, the host already has it and we add + // nothing — no wrapping `NavigationView`, no close button, and nothing done to their bar. + // The drill-downs that `NavigationLink` can't serve in that case are pushed through + // `CustomerCenterNavigating` instead. + var options = CustomerCenterNavigationOptions( + usesExistingNavigation: presentationStyle == .pushed, + showsCloseButton: presentationStyle == .modal + ) + super.init(rootView: CustomerCenterView(viewModel: viewModel, navigationOptions: options)) + + // The button actions need `self`, which isn't available until `super.init` has run. Assigning + // `rootView` again here is free: `CustomerCenterView` is a struct, and SwiftUI hasn't rendered + // it or installed its `@StateObject` yet. + options.onClose = { [weak self] in self?.dismiss(animated: true) } + + // Pushed, the host owns the navigation, so `NavigationLink` has no SwiftUI ancestor to work + // with and the drill-downs go through the navigator instead. + var navigator: CustomerCenterPushNavigator? + if presentationStyle == .pushed { + navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator?.presenter = self + pushNavigator = navigator + } + rootView = CustomerCenterView( + viewModel: viewModel, + navigationOptions: options, + navigator: navigator + ) + + if presentationStyle == .modal { + modalPresentationStyle = .pageSheet + } + } + + @available(*, unavailable) + required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) is not supported") } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + wasPresentedModally = presentingViewController != nil + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + guard isLeavingHierarchy else { + // Merely covered — a host push on top, a tab switch. `super` has just forwarded the + // disappearance into SwiftUI, whose `onDisappear` dropped the visible-surface count to zero + // and armed the dismissal debounce. That debounce is a heuristic for the embedded SwiftUI + // case, which has no way to tell a cover from a teardown; here we know, so veto it. Left to + // fire it would deliver `customerCenterDidDismiss()` and track `customerCenterClose` while + // the screen sits on the back stack, and latch, silencing the genuine teardown later. + viewModel.suppressDismissalUntilNextAppearance() + return + } + deliverDismissal() + } + + override public func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + // Being removed from a container. This is not redundant with `viewDidDisappear`: a controller + // that was already covered — the host pushed its own screen on top — has disappeared once + // already, so popping it off the stack produces no second disappearance and the teardown would + // otherwise never be delivered at all. + if parent == nil { + deliverDismissal() + } + } + + /// A view controller on its way out knows definitively that the Customer Center is gone, so fire + /// the view model's dismissal now rather than waiting out its visibility debounce — `onDismiss` + /// releases the manager's retained delegate, and a debounced dismissal would land after that + /// release and reach a nil delegate. + /// + /// Latched because the two call sites overlap: an ordinary pop is both a disappearance and a + /// removal from the container. `viewModel.dismiss()` is idempotent on its own, but `onDismiss` + /// is the manager's cleanup and must not run twice. + private func deliverDismissal() { + guard !hasDeliveredDismissal else { return } + hasDeliveredDismissal = true + viewModel.dismiss() + onDismiss?() + } + + /// Whether this disappearance is the Customer Center actually going away, rather than it being + /// covered by something the host put on top. + /// + /// UIKit sets `isBeingDismissed`/`isMovingFromParent` only on the controller it is directly + /// removing, so a Customer Center inside a container the host tears down — a navigation + /// controller that gets presented and later dismissed, say — has to look up the chain too. + /// + /// Testing `presentingViewController == nil` on its own would be wrong: it is `nil` for the + /// entire lifetime of a controller pushed onto a stack that isn't itself presented, so every + /// cover event would read as a teardown, fire `customerCenterDidDismiss()` while the screen sat + /// on the back stack, and — because `dismiss()` latches — leave the real teardown silent. + /// Paired with ``wasPresentedModally`` it becomes a sound signal again, and it backstops the + /// modal path in case a dismissal ever completes with `isBeingDismissed` already cleared. + private var isLeavingHierarchy: Bool { + if isLeavingHierarchyOrAContainerIs { + return true + } + return wasPresentedModally && presentingViewController == nil + } +} + +@available(iOS 15.0, *) +extension UIViewController { + /// Whether this controller, or any container it sits inside, is on its way out. + /// + /// UIKit sets `isBeingDismissed`/`isMovingFromParent` only on the controller it is directly + /// removing, so a screen inside a container the host tears down — a navigation controller that + /// was presented and is later dismissed, say — reads as merely covered unless the chain is + /// walked. Both the root Customer Center and its pushed drill-downs have to make that + /// distinction, and getting it wrong in either place turns a real teardown into a veto. + var isLeavingHierarchyOrAContainerIs: Bool { + var controller: UIViewController? = self + while let current = controller { + if current.isBeingDismissed || current.isMovingFromParent { + return true + } + controller = current.parent + } + return false + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift new file mode 100644 index 0000000000..42082ad27b --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -0,0 +1,320 @@ +// +// CustomerCenterDependencies.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Combine +import Foundation +import UIKit + +protocol CustomerCenterCustomerInfoProviding: AnyObject { + func fetchCustomerInfo() async -> CustomerInfo + /// Reloads receipts/entitlements from StoreKit before returning fresh customer info. Use + /// after Apple's manage-subscriptions or change-plan sheet closes: cancelling auto-renew + /// there emits no `Transaction.updates`, so a cached read would miss the change. + func refreshReceipts() async -> CustomerInfo + var customerInfoPublisher: AnyPublisher { get } +} +protocol CustomerCenterProductsProviding { + func products(for ids: Set) async -> [String: ProductDisplayInfo] +} +protocol CustomerCenterRestoring { + func restorePurchases() async -> RestorationResult +} +protocol CustomerCenterURLOpening { + var canOpenURLs: Bool { get } + func canOpen(_ url: URL) -> Bool + func open(_ url: URL) +} +protocol CustomerCenterEventTracking { + func track(_ event: Trackable) async +} +protocol CustomerCenterEnvironmentProviding { + var appVersion: String { get } + var osVersion: String { get } + var deviceModel: String { get } + var sdkVersion: String { get } + var userId: String { get } + var isSandbox: Bool { get } + var appStoreURL: URL? { get } + var webManagementURL: URL? { get } + var isSimulator: Bool { get } + var isAppExtension: Bool { get } + var originalDownloadDate: Date? { get } + var locale: Locale { get } +} + +struct CustomerCenterDependencies { + var customerInfo: CustomerCenterCustomerInfoProviding + var products: CustomerCenterProductsProviding + var restore: CustomerCenterRestoring + var urlOpener: CustomerCenterURLOpening + var tracker: CustomerCenterEventTracking + var environment: CustomerCenterEnvironmentProviding + var transactionLookup: StoreKitTransactionLooking + var appStoreVersion: CustomerCenterAppStoreVersionProviding +} + +enum WebManagementURLResolver { + static func resolve(override: URL?, restoreAccessURL: URL?) -> URL? { + if let override { return override } + guard let restoreAccessURL else { return nil } + guard + let host = restoreAccessURL.host, host == "superwall.app" || host.hasSuffix(".superwall.app"), + var components = URLComponents(url: restoreAccessURL, resolvingAgainstBaseURL: false) + else { + return restoreAccessURL + } + components.path = "/manage" + components.query = nil + components.fragment = nil + return components.url ?? restoreAccessURL + } +} + +extension ProductDisplayInfo { + /// - Parameter name: A display name from outside StoreKit — the Superwall catalogue, for a web + /// product StoreKit can't resolve. Ignored when `nil` or empty, leaving the usual fallbacks. + init(_ product: StoreProduct, name: String? = nil) { + var displayName: String? + if #available(iOS 15.0, *), let name = product.sk2Product?.displayName, !name.isEmpty { + displayName = name + } else if let name = product.sk1Product?.localizedTitle, !name.isEmpty { + displayName = name + } + if let name, !name.isEmpty { + displayName = name + } + var isAutoRenewable: Bool? + if #available(iOS 15.0, *), let type = product.sk2Product?.type { + isAutoRenewable = type == .autoRenewable + } + self.init( + productId: product.productIdentifier, + title: displayName, + localizedPrice: product.localizedPrice, + price: product.price, + localizedPeriod: product.subscriptionPeriod == nil ? nil : product.period, + subscriptionGroupId: product.subscriptionGroupIdentifier, + isAutoRenewable: isAutoRenewable + ) + } +} + +// MARK: - Live adapters + +@available(iOS 15.0, *) +final class LiveCustomerInfoProvider: CustomerCenterCustomerInfoProviding { + func fetchCustomerInfo() async -> CustomerInfo { await Superwall.shared.getCustomerInfo() } + func refreshReceipts() async -> CustomerInfo { + await Superwall.shared.dependencyContainer.receiptManager.loadPurchasedProducts(config: nil) + return await Superwall.shared.getCustomerInfo() + } + var customerInfoPublisher: AnyPublisher { Superwall.shared.$customerInfo.eraseToAnyPublisher() } +} +@available(iOS 15.0, *) +struct LiveProductsProvider: CustomerCenterProductsProviding { + let container: DependencyContainer + + func products(for ids: Set) async -> [String: ProductDisplayInfo] { + guard !ids.isEmpty else { return [:] } + let products = await Superwall.shared.products(for: ids) + var resolved = Dictionary(uniqueKeysWithValues: products.map { ($0.productIdentifier, ProductDisplayInfo($0)) }) + + // StoreKit only knows App Store products, so a subscription bought on the web resolves to + // nothing and its card falls back to showing a raw product identifier with no price. Those + // products are in the Superwall catalogue with their price, so fill the gaps from there. + let missing = ids.subtracting(resolved.keys) + guard !missing.isEmpty else { return resolved } + do { + // Bounded deliberately. This sits on the path that leaves `.loading`, and the endpoint's + // defaults are six retries with exponential backoff and no timeout — a failing backend + // would otherwise hold the spinner for minutes on a screen whose prices are a nicety. + let response = try await CatalogueCache.shared.products { + try await withCatalogueTimeout { + try await container.network.getSuperwallProducts() + } + } + resolved = Self.fillingGaps(in: resolved, requested: ids, from: response.data) + } catch { + // Advisory: the cards still render, just without a price. + Logger.debug( + logLevel: .warn, + scope: .customerCenter, + message: "Couldn't load Superwall products, so web purchases will show without a price.", + error: error + ) + } + return resolved + } + + /// Fills the products StoreKit couldn't resolve from the Superwall catalogue. + /// + /// A static function on purpose: `products(for:)` reaches `Superwall.shared` and the container's + /// network, neither of which a test can stand in for, so the rule would otherwise ship + /// uncovered — and the rule is where the judgement is. + /// + /// The gap is every id StoreKit didn't return, which includes App Store products whenever a + /// StoreKit lookup fails — `products(for:)` swallows that with `try?`. Filling those from the + /// catalogue would quote the dashboard's storefront price instead of what the customer is + /// actually charged, so this is restricted to products StoreKit was never going to resolve. + static func fillingGaps( + in resolved: [String: ProductDisplayInfo], + requested ids: Set, + from catalogue: [SuperwallProduct] + ) -> [String: ProductDisplayInfo] { + let missing = ids.subtracting(resolved.keys) + guard !missing.isEmpty else { return resolved } + var filled = resolved + for product in catalogue + where missing.contains(product.identifier) && product.platform != .ios { + let entitlements = Set(product.entitlements.map { Entitlement(id: $0.identifier) }) + let apiProduct = APIStoreProduct(superwallProduct: product, entitlements: entitlements) + let storeProduct = StoreProduct(catalogProduct: apiProduct) + filled[product.identifier] = ProductDisplayInfo(storeProduct, name: product.name) + } + return filled + } + + /// How long the catalogue gets before the screen gives up on prices and renders without them. + private static let catalogueTimeout: TimeInterval = 5 + + func withCatalogueTimeout( + _ work: @escaping () async throws -> SuperwallProductsResponse + ) async throws -> SuperwallProductsResponse { + try await withThrowingTaskGroup(of: SuperwallProductsResponse.self) { group in + group.addTask { try await work() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.catalogueTimeout * 1_000_000_000)) + throw CancellationError() + } + defer { group.cancelAll() } + guard let first = try await group.next() else { + throw CancellationError() + } + return first + } + } +} +/// Holds the Superwall catalogue for a short while. +/// +/// `apply(customerInfo:refetchProducts:)` runs on load, on every restore, and after every sheet +/// dismissal, and all four call sites refetch — so without this a web-store customer pays for the +/// catalogue several times inside one visit, for a product list that changes on the dashboard's +/// timescale rather than the customer's. The window is deliberately short: long enough to cover a +/// single visit, short enough that a price edit shows up the next time anyone opens the screen. +@available(iOS 15.0, *) +actor CatalogueCache { + static let shared = CatalogueCache() + static let ttl: TimeInterval = 5 * 60 + + private var cached: (response: SuperwallProductsResponse, at: Date)? + /// The fetch in progress, if any, so a caller arriving mid-flight shares it rather than starting + /// its own. An actor is reentrant across `await`: without this, two `apply` calls overlapping on + /// a cold cache — `load()` and the customer-info publisher can do exactly that — both pass the + /// freshness check and both fetch. + private var inFlight: Task? + private let now: () -> Date + + init(now: @escaping () -> Date = Date.init) { + self.now = now + } + + /// Returns the cached catalogue when it is still fresh, otherwise awaits `fetch` and keeps it. + /// A throwing `fetch` is not cached — a failed load should be retried, not remembered — and + /// every caller sharing that flight sees the same error. + func products( + fetch: @escaping () async throws -> SuperwallProductsResponse + ) async throws -> SuperwallProductsResponse { + if let cached, isFresh(cached.at) { + return cached.response + } + if let inFlight { + return try await inFlight.value + } + let task = Task { try await fetch() } + inFlight = task + defer { inFlight = nil } + let response = try await task.value + cached = (response, now()) + return response + } + + func isFresh(_ date: Date) -> Bool { + now().timeIntervalSince(date) < Self.ttl + } +} + +@available(iOS 15.0, *) +struct LiveRestorer: CustomerCenterRestoring { + func restorePurchases() async -> RestorationResult { + await Superwall.shared.restorePurchases(presentsFailureAlert: false) + } +} +struct LiveURLOpener: CustomerCenterURLOpening { + var canOpenURLs: Bool { UIApplication.sharedApplication != nil } + func canOpen(_ url: URL) -> Bool { UIApplication.sharedApplication?.canOpenURL(url) ?? false } + func open(_ url: URL) { UIApplication.sharedApplication?.open(url) } +} +@available(iOS 15.0, *) +struct LiveEventTracker: CustomerCenterEventTracking { + func track(_ event: Trackable) async { _ = await Superwall.shared.track(event) } +} +@available(iOS 15.0, *) +struct LiveEnvironment: CustomerCenterEnvironmentProviding { + let container: DependencyContainer + let webManagementOverride: URL? + var appVersion: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" } + var osVersion: String { UIDevice.current.systemVersion } + var deviceModel: String { UIDevice.current.model } + var sdkVersion: String { SuperwallKit.sdkVersion } + var userId: String { Superwall.shared.userId } + /// Deliberately `DeviceHelper`'s detection rather than `ReceiptManager.isSandboxEnvironment` + /// directly: that static is only ever assigned inside an `#available(iOS 16.0, *)` branch, so on + /// iOS 15 — the Customer Center's own floor — it stays nil and every sandbox check silently + /// reads `false`. `DeviceHelper` falls back to the simulator flag and the receipt URL, and also + /// accounts for test mode. + var isSandbox: Bool { container.deviceHelper.isSandbox == "true" } + var appStoreURL: URL? { + let id = container.makeAppId() ?? ReceiptManager.appId.map(String.init) + return id.flatMap { URL(string: "https://apps.apple.com/app/id\($0)") } + } + var webManagementURL: URL? { + WebManagementURLResolver.resolve( + override: webManagementOverride, + restoreAccessURL: container.makeRestoreAccessURL() + ) + } + var isSimulator: Bool { RuntimeUtils.isSimulator } + var isAppExtension: Bool { Bundle.main.bundlePath.hasSuffix(".appex") } + var originalDownloadDate: Date? { container.deviceHelper.appInstallDateValue } + var locale: Locale { Locale(identifier: container.deviceHelper.preferredLocaleIdentifier) } +} + +extension CustomerCenterDependencies { + @available(iOS 15.0, *) + static func live(container: DependencyContainer, configuration: CustomerCenterConfiguration) -> CustomerCenterDependencies { + CustomerCenterDependencies( + customerInfo: LiveCustomerInfoProvider(), + products: LiveProductsProvider(container: container), + restore: LiveRestorer(), + urlOpener: LiveURLOpener(), + tracker: LiveEventTracker(), + environment: LiveEnvironment(container: container, webManagementOverride: configuration.support.webManagementURL), + transactionLookup: StoreKitTransactionLookup(), + appStoreVersion: AppStoreVersionLookup() + ) + } +} + +enum RuntimeUtils { + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift new file mode 100644 index 0000000000..952d4fe736 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift @@ -0,0 +1,50 @@ +// +// CustomerCenterViewModel+PurchaseDetail.swift +// +// +// Created by Jordan Morgan on 11/09/2026. +// + +import Foundation + +// MARK: - What the purchase detail screen shows below the card + +@available(iOS 15.0, *) +extension CustomerCenterViewModel { + /// Why a detail screen has no actions, when it has none. Every subscription row opens its + /// detail screen — that is the rule, and it holds whether or not there is anything left to do — + /// so the screen has to say something true when the resolver comes back empty, and what is + /// true depends on the purchase. + enum DetailEmptyState: Equatable { + /// Nothing is left to do: the subscription is revoked or lapsed, the member isn't the one + /// paying, or a comped grant has no page to send anyone to. + case nothingToDo + /// The customer is still paying for this, from a store this SDK can't drive — the Play Store + /// on an iOS client, or a developer's own system. Telling them there is nothing to manage + /// would be false; telling them where to manage it is the `webManageUnavailable` precedent. + /// `storeLabelKey` names the store when there is a name for it. + case managedElsewhere(storeLabelKey: String?) + } + + /// Whether the detail screen for `purchase` has anything to act on. + func hasActions(for purchase: PurchasePresentation) -> Bool { + !paths(for: purchase, isScreenLevel: false).isEmpty + } + + /// `nil` when the detail screen has actions to show; otherwise which sentence to show instead. + func detailEmptyState(for purchase: PurchasePresentation) -> DetailEmptyState? { + if hasActions(for: purchase) { return nil } + // A lifetime grant is excluded on purpose: nothing renews, so there is nothing to manage + // anywhere — the same guard the resolver applies to change-plan. Sending its owner to the + // Play Store to "manage this subscription" would point at a renewal that doesn't exist. + let isLive = purchase.isActive && purchase.badge != .revoked && purchase.badge != .lifetime + let isDrivable: Bool = [.appStore, .stripe, .paddle, .superwall].contains(purchase.store) + guard isLive, !isDrivable else { return .nothingToDo } + // Only a store with a real name fills the sentence. `.other` and `.custom` carry the label + // "Other", and "manage this subscription through Other" is worse than the generic line. + switch purchase.store { + case .playStore: return .managedElsewhere(storeLabelKey: purchase.storeLabelKey) + default: return .managedElsewhere(storeLabelKey: nil) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+Support.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+Support.swift new file mode 100644 index 0000000000..4cf2a4169e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+Support.swift @@ -0,0 +1,43 @@ +// +// CustomerCenterViewModel+Support.swift +// +// +// Created by Jordan Morgan on 25/08/2026. +// + +import Foundation + +// MARK: - Support email + +@available(iOS 15.0, *) +extension CustomerCenterViewModel { + var supportMailtoURL: URL? { + SupportEmailComposer.mailtoURL( + email: configuration.support.email, + subject: strings.string("customer_center_support_subject"), + body: strings.string("customer_center_support_body"), + diagnostics: diagnostics + ) + } + + private var diagnostics: SupportEmailDiagnostics { + let env = dependencies.environment + return .init( + userId: env.userId, + appVersion: env.appVersion, + osVersion: env.osVersion, + deviceModel: env.deviceModel, + sdkVersion: env.sdkVersion, + activeEntitlementIds: activeEntitlementIds, + isSandbox: env.isSandbox + ) + } + + /// Whether to show the contact-support path. + /// + /// Gated only on a support email being configured. `canOpenURL("mailto:…")` returns false on + /// device unless the host app declares `mailto` in `LSApplicationQueriesSchemes`, so + /// pre-gating on it would hide the path entirely for most apps. The tap handler falls back to + /// a sheet showing the address instead. + var supportEmailAvailable: Bool { supportMailtoURL != nil } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+UpdateBanner.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+UpdateBanner.swift new file mode 100644 index 0000000000..42ca5ecf43 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+UpdateBanner.swift @@ -0,0 +1,54 @@ +// +// CustomerCenterViewModel+UpdateBanner.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Foundation + +// MARK: - Update banner + +@available(iOS 15.0, *) +extension CustomerCenterViewModel { + /// The version the banner compares against: whatever the host configured, otherwise whatever the + /// App Store lookup returned. A configured value always wins and suppresses the lookup entirely. + private var latestKnownAppVersion: String? { + configuration.support.latestAppVersion ?? fetchedAppStoreVersion + } + + func recomputeUpdateBanner() { + showsUpdateBanner = !updateWarningDismissed + && configuration.support.shouldWarnToUpdate + && AppVersionComparator.isInstalledVersion( + dependencies.environment.appVersion, + olderThan: latestKnownAppVersion + ) + } + + /// Asks the App Store what version is published, then re-evaluates the banner. + /// + /// Skipped on TestFlight, sandbox and simulator builds: their version is normally *ahead* of the + /// published one, so the comparison would either be meaningless or send a tester "back" to an + /// older build. Also skipped when the host set `latestAppVersion`, which is authoritative. + func refreshAppStoreVersion() async { + guard + configuration.support.shouldWarnToUpdate, + configuration.support.checksAppStoreForUpdates, + configuration.support.latestAppVersion == nil, + !dependencies.environment.isSandbox, + !hasCheckedAppStoreVersion + else { + return + } + hasCheckedAppStoreVersion = true + guard let version = await dependencies.appStoreVersion.latestAppStoreVersion() else { return } + fetchedAppStoreVersion = version + recomputeUpdateBanner() + } + + func continueAfterUpdateWarning() { + updateWarningDismissed = true + showsUpdateBanner = false + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift new file mode 100644 index 0000000000..119ed55afb --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -0,0 +1,388 @@ +// +// CustomerCenterViewModel.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Combine +import Foundation + +/// Drives the Customer Center UI: loads customer info and products, resolves paths, and performs actions. +@available(iOS 15.0, *) +@MainActor +final class CustomerCenterViewModel: ObservableObject { + typealias PendingSurvey = (path: CustomerCenterConfiguration.Path, survey: CustomerCenterConfiguration.FeedbackSurvey) + typealias PendingAction = (resolved: ResolvedPath, purchase: PurchasePresentation?) + + @Published private(set) var state: CustomerCenterScreenState = .loading + @Published private(set) var purchases: [PurchasePresentation] = [] + @Published var sheet: CustomerCenterSheet? { + didSet { if let sheet { lastPresentedSheet = sheet } } + } + @Published var restoreState: CustomerCenterRestoreState = .idle + @Published private(set) var refundResult: (productId: String, status: CustomerCenterRefundStatus)? + // Not `private(set)`: the update-banner logic lives in + // `CustomerCenterViewModel+UpdateBanner.swift`, and `private` is file-scoped. + @Published var showsUpdateBanner = false + @Published private(set) var showsDuplicateBanner = false + /// How many of the Customer Center's own screens the host has pushed above the root, when it + /// owns the navigation. Only the surface at this depth presents sheets. + @Published var pushDepth = 0 + + let configuration: CustomerCenterConfiguration + let strings: CustomerCenterStrings + var callbacks = CustomerCenterCallbacks() + var presentationMode = "sheet" + private(set) var pendingSurvey: PendingSurvey? + + /// Locale for date formatting, matching the locale the localized strings resolve against: + /// `DeviceHelper.preferredLocaleIdentifier`, the device's preferred language. Not + /// `SuperwallOptions.localeIdentifier`, which applies only when there are no preferred + /// languages at all. + var locale: Locale { dependencies.environment.locale } + + // Not `private`: the support-email extension in `CustomerCenterViewModel+Support.swift` + // reads these, and `private` is file-scoped. + let dependencies: CustomerCenterDependencies + private let dismissDebounceInterval: TimeInterval + private let isChangePlanSheetAvailable: Bool + private var products: [String: ProductDisplayInfo] = [:] + private var familyShared: Set = [] + private var pendingAction: PendingAction? + /// An action deferred from `answerSurvey` until the survey sheet has finished dismissing. + /// Performing it immediately would present a new sheet while the old one is still animating + /// out, which iOS 15/16 can silently drop. + private var pendingActionAfterSheetDismiss: PendingAction? + /// The most recent non-nil ``sheet``, so ``sheetDidDismiss()`` knows whether the sheet that + /// just closed was a StoreKit store sheet requiring a receipt refresh. + private var lastPresentedSheet: CustomerCenterSheet? + var updateWarningDismissed = false + /// Version read from the App Store, used when the host didn't configure one. + var fetchedAppStoreVersion: String? + var hasCheckedAppStoreVersion = false + private var hasTrackedOpen = false + private var didDismiss = false + /// Active entitlement identifiers from the latest `CustomerInfo`, for support diagnostics. + var activeEntitlementIds: [String] = [] + private var cancellables = Set() + + /// Number of Customer Center surfaces (root + any pushed screens) currently on screen. + /// Incremented/decremented by ``surfaceDidAppear()``/``surfaceDidDisappear()``. When this + /// reaches zero and stays zero past the debounce, the Customer Center is genuinely gone. + private var visibleSurfaceCount = 0 + private var dismissDebounceTask: Task? + /// Set by a host that knows this disappearance is a cover rather than a teardown. Cleared the + /// next time a surface appears. See ``suppressDismissalUntilNextAppearance()``. + private var isDismissalSuppressed = false + + init( + configuration: CustomerCenterConfiguration, + dependencies: CustomerCenterDependencies, + strings: CustomerCenterStrings, + isChangePlanSheetAvailable: Bool? = nil, + // Comfortably longer than a UINavigationController push/pop (~0.35s). During a pop the + // outgoing screen's `onDisappear` can land before the root's `onAppear`, so the count dips to + // zero mid-transition; the debounce has to outlast that or a dismissal fires while the user is + // still inside. Only delays how soon `didDismiss` reaches the host, which nothing is gated on. + dismissDebounceInterval: TimeInterval = 0.6 + ) { + self.configuration = configuration + self.dependencies = dependencies + self.strings = strings + self.dismissDebounceInterval = dismissDebounceInterval + if let isChangePlanSheetAvailable { + self.isChangePlanSheetAvailable = isChangePlanSheetAvailable + } else if #available(iOS 17.0, *) { + self.isChangePlanSheetAvailable = true + } else { + self.isChangePlanSheetAvailable = false + } + dependencies.customerInfo.customerInfoPublisher + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] info in + guard let self else { return } + Task { await self.apply(customerInfo: info, refetchProducts: true) } + } + .store(in: &cancellables) + } + + // MARK: - Loading + + func load() async { + let info = await dependencies.customerInfo.fetchCustomerInfo() + await apply(customerInfo: info, refetchProducts: true) + if !hasTrackedOpen { + hasTrackedOpen = true + await dependencies.tracker.track( + InternalSuperwallEvent.CustomerCenterOpen( + screen: state == .management ? "management" : "no_purchases", + presentation: presentationMode + ) + ) + } + // Last, and deliberately so: this makes a network call, and everything above it — the first + // render and the open event — must not wait on it. + // + // That orders open before the *lookup*, not before every close: `apply` above still awaits a + // StoreKit round trip, so closing while the spinner is up can still emit close first. Moving + // open ahead of `apply` would fix that and put the whole tracking pipeline in front of first + // paint, which is the worse trade. + await refreshAppStoreVersion() + } + + private func apply(customerInfo: CustomerInfo, refetchProducts: Bool) async { + let ids = Set(customerInfo.subscriptions.map(\.productId) + customerInfo.nonSubscriptions.map(\.productId)) + if refetchProducts { + products = await dependencies.products.products(for: ids) + var shared: Set = [] + for id in customerInfo.subscriptions.filter({ $0.store == .appStore }).map(\.productId) + where await dependencies.transactionLookup.isFamilyShared(productId: id) { + shared.insert(id) + } + familyShared = shared + } + let builder = PurchasePresentationBuilder(strings: strings, locale: dependencies.environment.locale) + purchases = builder.build(customerInfo: customerInfo, products: products) + activeEntitlementIds = customerInfo.entitlements.filter(\.isActive).map(\.id) + state = hasAnyPurchases(customerInfo) ? .management : .noPurchases + recomputeUpdateBanner() + let activeStores = Set(customerInfo.subscriptions.filter(\.isActive).map(\.store)) + showsDuplicateBanner = configuration.warnsAboutDuplicateSubscriptions + && activeStores.contains(.appStore) + && !activeStores.isDisjoint(with: [.stripe, .paddle, .superwall]) + } + + /// Whether `info` represents any purchase the Customer Center should show as "management" — + /// a subscription, a non-subscription transaction, or an active entitlement (which covers + /// manually granted and cross-store entitlements that have no local transaction). + private func hasAnyPurchases(_ info: CustomerInfo) -> Bool { + !info.subscriptions.isEmpty || !info.nonSubscriptions.isEmpty || info.entitlements.contains { $0.isActive } + } + + // MARK: - Paths + + var userId: String { dependencies.environment.userId } + var originalDownloadDate: Date? { dependencies.environment.originalDownloadDate } + var appStoreURL: URL? { dependencies.environment.appStoreURL } + + /// Resolves the paths to show. + /// - Parameters: + /// - purchase: The purchase the paths apply to, if any. + /// - isScreenLevel: `true` for a screen's main action list (management / no-purchases), where + /// restore is always available; `false` for a drilled-in purchase detail screen. + func paths(for purchase: PurchasePresentation?, isScreenLevel: Bool = true) -> [ResolvedPath] { + let screen = state == .noPurchases ? configuration.noPurchasesScreen : configuration.managementScreen + let context = PathResolutionContext( + purchase: purchase, + product: purchase?.productId.flatMap { products[$0] }, + isFamilyShared: purchase?.productId.map { familyShared.contains($0) } ?? false, + supportEmailAvailable: supportEmailAvailable, + webManagementURL: dependencies.environment.webManagementURL, + isChangePlanSheetAvailable: isChangePlanSheetAvailable, + canOpenURLs: dependencies.urlOpener.canOpenURLs && !dependencies.environment.isAppExtension, + isScreenLevel: isScreenLevel + ) + return CustomerCenterPathResolver.resolve(screen.paths, context: context) + } + + func select(_ resolved: ResolvedPath, purchase: PurchasePresentation?) async { + let action = CustomerCenterAction(pathType: resolved.path.type) + callbacks.didSelectAction?(action, purchase?.subscription) + await dependencies.tracker.track( + InternalSuperwallEvent.CustomerCenterAction(action: action, pathId: resolved.path.id, productId: purchase?.productId) + ) + if let survey = resolved.path.survey, !survey.options.isEmpty, !resolved.destination.isWebManagement { + pendingSurvey = (resolved.path, survey) + pendingAction = (resolved, purchase) + sheet = .survey(pathId: resolved.path.id) + return + } + await perform(resolved, purchase: purchase) + } + + func answerSurvey(optionId: String) async { + guard let pendingSurvey, let pendingAction else { return } + let action = CustomerCenterAction(pathType: pendingAction.resolved.path.type) + callbacks.didCompleteSurvey?(pendingSurvey.survey.id, optionId, action) + await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterSurveyResponse( + surveyId: pendingSurvey.survey.id, + optionId: optionId, + action: action, + pathId: pendingAction.resolved.path.id, + productId: pendingAction.purchase?.productId + )) + self.pendingSurvey = nil + self.pendingAction = nil + // Don't perform the follow-up action yet: it may present another sheet, and doing so while + // the survey sheet is still animating out can be dropped on iOS 15/16. It's performed by + // `sheetDidDismiss()` once the survey sheet has finished dismissing. + pendingActionAfterSheetDismiss = pendingAction + sheet = nil + } + + func cancelSurvey() { + pendingSurvey = nil + pendingAction = nil + pendingActionAfterSheetDismiss = nil + if case .survey = sheet { sheet = nil } + } + + private func perform(_ resolved: ResolvedPath, purchase: PurchasePresentation?) async { + switch resolved.destination { + case .restore: + await performRestore() + case .appleManageSheet(let groupId): + sheet = .manageSubscriptions(groupId: groupId) + case .webManage(let url): + sheet = .safari(url) + case .webManageUnavailable: + sheet = .webManageUnavailable + case .refund(let productId): + if let transactionId = await dependencies.transactionLookup.latestTransactionID(for: productId) { + sheet = .refund(transactionId: transactionId, productId: productId) + } else { + await refundSheetDidFinish(productId: productId, status: .error) + } + case let .changePlan(groupId, productIds): + sheet = .changePlan(groupId: groupId, productIds: productIds) + case .contactSupport: + guard let url = supportMailtoURL else { return } + // The path row itself is no longer gated on `canOpen` (see `supportEmailAvailable`), so + // the fallback happens here at tap time: open the composer when we can, otherwise show + // the address so the user can still reach support manually. + if dependencies.urlOpener.canOpen(url) { + dependencies.urlOpener.open(url) + } else { + sheet = .noMailApp(email: configuration.support.email ?? "") + } + case let .url(url, inApp): + if inApp { sheet = .safari(url) } else { dependencies.urlOpener.open(url) } + case .custom: + break + } + } + + // MARK: - Restore + + func performRestore() async { + if let gate = callbacks.shouldRestore { + let proceed = await withCheckedContinuation { continuation in gate { continuation.resume(returning: $0) } } + guard proceed else { return } + } + restoreState = .restoring + let delay = Task { try? await Task.sleep(nanoseconds: 500_000_000) } + let result = await dependencies.restore.restorePurchases() + await delay.value + let info = await dependencies.customerInfo.fetchCustomerInfo() + await apply(customerInfo: info, refetchProducts: true) + let hasPurchases = hasAnyPurchases(info) + switch result { + case .restored where hasPurchases: restoreState = .restored + default: restoreState = .notFound + } + } + + // MARK: - Sheet callbacks + + func refundSheetDidFinish(productId: String, status: CustomerCenterRefundStatus) async { + refundResult = (productId, status) + callbacks.didCompleteRefund?(productId, status) + await dependencies.tracker.track( + InternalSuperwallEvent.CustomerCenterRefundRequest(productId: productId, status: status) + ) + sheet = nil + } + + /// Call when any Customer Center sheet finishes dismissing. Performs any action deferred by + /// `answerSurvey`, then reloads to pick up changes. When the dismissed sheet was a StoreKit + /// store sheet (manage subscriptions / change plan), receipts are reloaded first: cancelling + /// auto-renew in Apple's sheet emits no `Transaction.updates`, so a plain cached + /// customer-info read would miss the change. + func sheetDidDismiss() async { + let dismissed = lastPresentedSheet + lastPresentedSheet = nil + // Perform the deferred survey follow-up BEFORE the refetch, now that the previous sheet + // has finished dismissing and a new one can be presented reliably. + if let pending = pendingActionAfterSheetDismiss { + pendingActionAfterSheetDismiss = nil + await perform(pending.resolved, purchase: pending.purchase) + } + let info: CustomerInfo + switch dismissed { + case .manageSubscriptions, .changePlan: + info = await dependencies.customerInfo.refreshReceipts() + default: + info = await dependencies.customerInfo.fetchCustomerInfo() + } + await apply(customerInfo: info, refetchProducts: true) + } +} + +// MARK: - Visibility-driven dismissal + +@available(iOS 15.0, *) +extension CustomerCenterViewModel { + /// Call from any Customer Center surface's `onAppear` — the root view, and any screen it pushes + /// itself. Pushing a screen removes the previous surface from the hierarchy without the Customer + /// Center closing, so a count of concurrently visible surfaces (rather than a boolean) is what + /// tracks nested pushes correctly. Also cancels any pending dismissal from a prior disappear. + func surfaceDidAppear() { + visibleSurfaceCount += 1 + isDismissalSuppressed = false + dismissDebounceTask?.cancel() + dismissDebounceTask = nil + } + + /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear()``. + /// When the count drops to zero, waits out a debounce before dismissing: a push/pop transition can + /// briefly have both surfaces on screen or neither, so one runloop turn can't tell "navigating + /// within the Customer Center" from "the Customer Center was torn down". An appearance before the + /// debounce elapses cancels it. + func surfaceDidDisappear() { + visibleSurfaceCount = max(0, visibleSurfaceCount - 1) + guard visibleSurfaceCount == 0, !isDismissalSuppressed else { return } + dismissDebounceTask?.cancel() + // Captures self strongly: on the SwiftUI sheet path the last `onDisappear` is immediately + // followed by `@StateObject` releasing the view model, and a weak capture would let it + // deallocate before the debounce elapses — silently dropping `didDismiss` and the + // `customerCenterClose` event. The task only outlives the view by the debounce interval. + dismissDebounceTask = Task { [dismissDebounceInterval] in + try? await Task.sleep(nanoseconds: UInt64(dismissDebounceInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + guard visibleSurfaceCount == 0 else { return } + dismiss() + } + } + + /// Drops a dismissal the visibility count scheduled but hasn't delivered. The count can't tell a + /// teardown from something being put on top, so it guesses; a host that knows better — a + /// `CustomerCenterViewController` being covered rather than removed — vetoes the guess here. + /// Left to fire, the premature ``dismiss()`` would latch and silence the genuine teardown. + func cancelPendingDismissal() { + dismissDebounceTask?.cancel() + dismissDebounceTask = nil + } + + /// The same veto, proof against the ordering it can't control. + /// + /// ``cancelPendingDismissal()`` only drops an already-armed dismissal, assuming SwiftUI has + /// delivered `onDisappear` by the time `viewDidDisappear` runs — a moment Apple documents as + /// view-type-dependent. Arriving a runloop turn later, it found nothing to cancel and armed + /// unopposed. Suppressing until the next appearance covers both orderings; a genuine teardown + /// is unaffected, since the controllers deliver those through ``dismiss()`` directly. + func suppressDismissalUntilNextAppearance() { + isDismissalSuppressed = true + cancelPendingDismissal() + } + + func dismiss() { + guard !didDismiss else { return } + didDismiss = true + dismissDebounceTask?.cancel() + dismissDebounceTask = nil + callbacks.didDismiss?() + Task { await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterClose()) } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift new file mode 100644 index 0000000000..cbfcefb021 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift @@ -0,0 +1,40 @@ +// +// AccountDetailsSection.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct AccountDetailsSection: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + @State private var copied = false + + var body: some View { + Section(strings.string("customer_center_account_details")) { + HStack { + VStack(alignment: .leading) { + Text(strings.string("customer_center_user_id")).font(.caption).foregroundStyle(.secondary) + Text(viewModel.userId).font(.footnote).lineLimit(1).truncationMode(.middle) + } + Spacer() + Button(strings.string(copied ? "customer_center_copied" : "customer_center_copy")) { + UIPasteboard.general.string = viewModel.userId + copied = true + } + .font(.footnote) + .accessibilityIdentifier("customer_center.copy_user_id") + } + if let date = viewModel.originalDownloadDate { + HStack { + Text(strings.string("customer_center_original_download_date")).font(.footnote) + Spacer() + Text(date, style: .date).font(.footnote).foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift new file mode 100644 index 0000000000..23c0c61c19 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift @@ -0,0 +1,43 @@ +// +// AppUpdateWarningView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct AppUpdateWarningView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + @Environment(\.openURL) private var openURL + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(strings.string("customer_center_update_title")).font(.headline) + Text(strings.string("customer_center_update_message")).font(.subheadline).foregroundStyle(.secondary) + HStack { + if let url = viewModel.appStoreURL { + Button(strings.string("customer_center_update_action")) { openURL(url) } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("customer_center.update") + } + Button(strings.string("customer_center_update_continue")) { + // Animated here rather than in the view model so the banner's removal from the list + // is part of the same transaction. `withAnimation(nil)` runs the change unanimated, + // which is what Reduce Motion should get. + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25)) { + viewModel.continueAfterUpdateWarning() + } + } + .buttonStyle(.bordered) + .accessibilityIdentifier("customer_center.update_continue") + } + } + .padding(.vertical, 4) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift new file mode 100644 index 0000000000..c6d96a214c --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift @@ -0,0 +1,56 @@ +// +// CustomerCenterEnvironment.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct CustomerCenterTheme { + var accent: Color? + + init(appearance: CustomerCenterConfiguration.Appearance, colorScheme: ColorScheme) { + func color(_ pair: CustomerCenterConfiguration.Appearance.ColorPair?) -> Color? { + guard let pair else { return nil } + return UIColor(hex: colorScheme == .dark ? pair.dark : pair.light).map(Color.init) + } + accent = color(appearance.accent) + } +} + +extension UIColor { + /// Parses `#RRGGBB` / `#RRGGBBAA` / `RRGGBB`. + convenience init?(hex: String) { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + guard value.count == 6 || value.count == 8, let int = UInt64(value, radix: 16) else { return nil } + let hasAlpha = value.count == 8 + let red = CGFloat((int >> (hasAlpha ? 24 : 16)) & 0xFF) / 255 + let green = CGFloat((int >> (hasAlpha ? 16 : 8)) & 0xFF) / 255 + let blue = CGFloat((int >> (hasAlpha ? 8 : 0)) & 0xFF) / 255 + let alpha = hasAlpha ? CGFloat(int & 0xFF) / 255 : 1 + self.init(red: red, green: green, blue: blue, alpha: alpha) + } +} + +@available(iOS 15.0, *) +private struct CustomerCenterStringsKey: EnvironmentKey { + static let defaultValue = CustomerCenterStrings.english +} +@available(iOS 15.0, *) +private struct CustomerCenterThemeKey: EnvironmentKey { + static let defaultValue = CustomerCenterTheme(appearance: .init(), colorScheme: .light) +} +@available(iOS 15.0, *) +extension EnvironmentValues { + var customerCenterStrings: CustomerCenterStrings { + get { self[CustomerCenterStringsKey.self] } + set { self[CustomerCenterStringsKey.self] = newValue } + } + var customerCenterTheme: CustomerCenterTheme { + get { self[CustomerCenterThemeKey.self] } + set { self[CustomerCenterThemeKey.self] = newValue } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift new file mode 100644 index 0000000000..b51d69cd6d --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift @@ -0,0 +1,67 @@ +// +// CustomerCenterNavigator.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import SwiftUI + +/// Pushes one of the Customer Center's own screens — purchase history, purchase detail. +/// +/// Exists because SwiftUI's `NavigationLink` needs a SwiftUI navigation ancestor, and a +/// `UINavigationController` isn't one. When a host pushes ``CustomerCenterViewController`` onto its +/// own stack there's no such ancestor, so the drill-downs have to be pushed through UIKit instead. +/// Everywhere else — presented modally, or embedded in the host's own SwiftUI navigation — this is +/// `nil` and `NavigationLink` does the work. +@available(iOS 15.0, *) +@MainActor +protocol CustomerCenterNavigating: AnyObject { + func push(_ destination: Destination) +} + +@available(iOS 15.0, *) +private struct CustomerCenterNavigatorKey: EnvironmentKey { + static let defaultValue: CustomerCenterNavigating? = nil +} + +@available(iOS 15.0, *) +extension EnvironmentValues { + var customerCenterNavigator: CustomerCenterNavigating? { + get { self[CustomerCenterNavigatorKey.self] } + set { self[CustomerCenterNavigatorKey.self] = newValue } + } +} + +/// A row that drills into another Customer Center screen, by whichever mechanism the surrounding +/// navigation supports. +@available(iOS 15.0, *) +struct CustomerCenterDrillDown: View { + @Environment(\.customerCenterNavigator) private var navigator + @ViewBuilder let destination: () -> Destination + @ViewBuilder let label: () -> Label + + var body: some View { + // Branching on `navigator` is safe: it's fixed for the lifetime of the hierarchy, so this + // can't flip mid-update and tear down a modifier that was about to do something. + if let navigator { + Button { + navigator.push(destination()) + } label: { + HStack { + label() + Spacer() + // `NavigationLink` draws its own chevron; this branch has to supply one, and the row + // does push, so the chevron is telling the truth. + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + NavigationLink(destination: destination(), label: label) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift new file mode 100644 index 0000000000..4f8c0454a8 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -0,0 +1,213 @@ +// +// CustomerCenterSheets.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SafariServices +import StoreKit +import SwiftUI + +@available(iOS 15.0, *) +extension View { + /// - Parameter surfaceDepth: How deep this screen sits in the Customer Center's own pushed + /// stack; `0` for the root. Passed rather than read from the environment because a modifier + /// resolves its `@Environment` against the values *above* it, so a caller that applied this + /// outside its `.environment(...)` writes would silently read the default and the gate would + /// stop working. + func customerCenterSheets(viewModel: CustomerCenterViewModel, surfaceDepth: Int = 0) -> some View { + modifier(CustomerCenterSheetsModifier(viewModel: viewModel, surfaceDepth: surfaceDepth)) + } +} + +/// Which surface owns sheet presentation. A free function so the rule the modifier applies can be +/// exercised directly rather than restated by a test. +enum CustomerCenterSheetOwnership { + static func isTopmost(surfaceDepth: Int, pushDepth: Int) -> Bool { + surfaceDepth == pushDepth + } + + /// The kind of sheet a boolean binding stands for. `CustomerCenterSheet`'s own cases carry + /// associated values, and a dismissal only needs to know which case it belongs to. + enum SheetKind { + case manageSubscriptions + case refund + } + + /// Whether a `false` write from a boolean sheet binding should clear `sheet`. + /// + /// SwiftUI writes `false` when the sheet that binding drives goes away — but it also writes it + /// when the *getter* flips false for another reason, which happens to every surface that stops + /// being topmost. Clearing unconditionally therefore lets a covered screen tear down the sheet + /// the visible one just opened, and run `sheetDidDismiss()` — receipt refresh included — + /// against a sheet that was never showing. Only the sheet actually up may clear itself. + static func dismissalClears(_ current: CustomerCenterSheet?, _ kind: SheetKind) -> Bool { + switch (current, kind) { + case (.manageSubscriptions, .manageSubscriptions), (.refund, .refund): return true + default: return false + } + } +} + +/// Internal rather than private so a test can drive the sheet bindings directly. The gate they +/// apply has been wrong twice — once inert, once over-eager — and both times the bug was in the +/// binding rather than in the rule it calls, which a test of the rule alone cannot catch. +@available(iOS 15.0, *) +struct CustomerCenterSheetsModifier: ViewModifier { + @ObservedObject var viewModel: CustomerCenterViewModel + let surfaceDepth: Int + @Environment(\.customerCenterStrings) private var strings + + /// Every screen still in the stack applies this modifier, so without a check they'd all try to + /// present the same sheet. Gating the bindings rather than the modifier keeps the view tree + /// stable — swapping modifiers mid-update is what stopped the manage sheet appearing once + /// before. + /// + /// Only the getters are gated on depth. The setters are gated on the sheet's identity instead: + /// gating them on depth too would let a screen lose the right to clear a sheet it already has + /// open, since the depth drops when the screen is popped without regard for whether a sheet is + /// up — the dismissal would be vetoed, `sheetDidDismiss()` would never run, and the root would + /// re-present the stale sheet the moment it became topmost again. + private var isTopmost: Bool { + CustomerCenterSheetOwnership.isTopmost( + surfaceDepth: surfaceDepth, + pushDepth: viewModel.pushDepth + ) + } + + var isManagePresented: Binding { + .init( + get: { + guard isTopmost, case .manageSubscriptions = viewModel.sheet else { return false } + return true + }, + set: { + guard !$0, CustomerCenterSheetOwnership.dismissalClears(viewModel.sheet, .manageSubscriptions) else { + return + } + viewModel.sheet = nil + Task { await viewModel.sheetDidDismiss() } + } + ) + } + var refundBinding: Binding { + .init( + get: { + guard isTopmost, case .refund = viewModel.sheet else { return false } + return true + }, + set: { + guard !$0, CustomerCenterSheetOwnership.dismissalClears(viewModel.sheet, .refund) else { return } + viewModel.sheet = nil + } + ) + } + private var itemSheet: Binding { + .init( + get: { + guard isTopmost else { return nil } + switch viewModel.sheet { + case .survey, .changePlan, .safari, .noMailApp, .webManageUnavailable: return viewModel.sheet + default: return nil + } + }, + set: { viewModel.sheet = $0 } + ) + } + private var manageGroupId: String? { + if case .manageSubscriptions(let id) = viewModel.sheet { return id } + return nil + } + private var refundTransactionId: UInt64 { + if case .refund(let id, _) = viewModel.sheet { return id } + return 0 + } + private var refundProductId: String { + if case .refund(_, let pid) = viewModel.sheet { return pid } + return "" + } + private var onItemSheetDismiss: () -> Void { + { + if viewModel.pendingSurvey != nil { viewModel.cancelSurvey() } + Task { await viewModel.sheetDidDismiss() } + } + } + + func body(content: Content) -> some View { + content + .modifier(ManageSubscriptionsSheet(isPresented: isManagePresented, groupId: manageGroupId)) + .refundRequestSheet(for: refundTransactionId, isPresented: refundBinding) { result in + let status: CustomerCenterRefundStatus + switch result { + case .success(.success): status = .success + case .success(.userCancelled): status = .userCancelled + case .success: status = .error + case .failure: status = .error + } + let productId = refundProductId + Task { await viewModel.refundSheetDidFinish(productId: productId, status: status) } + } + .sheet(item: itemSheet, onDismiss: onItemSheetDismiss) { sheet in + switch sheet { + case .survey: + FeedbackSurveyView(viewModel: viewModel) + case let .changePlan(groupId, productIds): + ChangePlanSheet(groupId: groupId, productIds: productIds) + case .safari(let url): + SafariView(url: url).ignoresSafeArea() + case .noMailApp(let email): + Text(strings.string("customer_center_no_mail_app", email)).padding() + case .webManageUnavailable: + Text(strings.string("customer_center_web_manage_unavailable")).padding() + default: + EmptyView() + } + } + } +} + +@available(iOS 15.0, *) +private struct ManageSubscriptionsSheet: ViewModifier { + let isPresented: Binding + let groupId: String? + func body(content: Content) -> some View { + // The branch must not depend on `groupId`. It is derived from `viewModel.sheet`, so it becomes + // non-nil in the very same update that flips `isPresented` to true — and swapping which + // modifier is applied during that update tears down the one that was about to present, so the + // sheet never appears. `#available` is constant for the process, so branching on it is safe. + if #available(iOS 17.0, *) { + content.manageSubscriptionsSheet( + isPresented: isPresented, + subscriptionGroupID: groupId ?? "" + ) + } else { + content.manageSubscriptionsSheet(isPresented: isPresented) + } + } +} + +@available(iOS 15.0, *) +private struct ChangePlanSheet: View { + let groupId: String? + let productIds: [String]? + var body: some View { + if #available(iOS 17.0, *) { + if let productIds, productIds.count >= 2 { + SubscriptionStoreView(productIDs: productIds) + } else if let groupId { + SubscriptionStoreView(groupID: groupId) + } else { + EmptyView() + } + } else { + EmptyView() // resolver hides changePlan below iOS 17 + } + } +} + +struct SafariView: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> SFSafariViewController { SFSafariViewController(url: url) } + func updateUIViewController(_ controller: SFSafariViewController, context: Context) {} +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift new file mode 100644 index 0000000000..e51ca550d7 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -0,0 +1,123 @@ +// +// CustomerCenterStrings+English.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation + +/// Minimal string provider so logic/tests don't depend on bundles. +struct CustomerCenterStrings { + var lookup: (String) -> String + + func string(_ key: String, _ args: CVarArg...) -> String { + let format = lookup(key) + return args.isEmpty ? format : String(format: format, arguments: args) + } + + /// English literals matching Task 9's `Localizable.strings` keys. + static let english = CustomerCenterStrings { key in englishStrings[key] ?? key } +} + +extension CustomerCenterStrings { + /// Strings backed by the SDK's localized bundles, falling back to English, then the key. + static func bundled(locale: Locale? = nil) -> CustomerCenterStrings { + let bundle = LocalizationLogic.localizedBundle(locale) + return CustomerCenterStrings { key in + let value = bundle.localizedString(forKey: key, value: "", table: nil) + if !value.isEmpty && value != key { return value } + return englishStrings[key] ?? key + } + } +} + +/// English literals keyed by localization key. Must match every `customer_center_` key in +/// `en.lproj/Localizable.strings`. +let englishStrings: [String: String] = [ + // Customer Center – screens + "customer_center_management_title": "Manage your subscription", + "customer_center_no_purchases_title": "No subscriptions found", + "customer_center_no_purchases_subtitle": "We can check for previous purchases.", + "customer_center_close": "Close", + "customer_center_done": "Done", + "customer_center_cancel": "Cancel", + // Customer Center – paths + "customer_center_path_restore": "Restore purchases", + // The value and the key differ on purpose: the key tracks `PathType.manageSubscription`, while the + // label says what the row does for the customer. In the default configuration this row carries the + // cancellation survey and opens Apple's sheet, where cancelling is the primary action. + "customer_center_path_manage_subscription": "Cancel subscription", + "customer_center_path_manage_subscription_web": "Manage subscription", + "customer_center_web_manage_unavailable": "Manage your subscription using the link in your emailed receipt.", + "customer_center_detail_nothing_to_manage": "There's nothing to manage for this purchase.", + "customer_center_detail_managed_through": "Manage this subscription through %@.", + "customer_center_detail_managed_where_bought": "Manage this subscription where you bought it.", + "customer_center_path_refund": "Request a refund", + "customer_center_path_change_plan": "Change plan", + "customer_center_path_contact_support": "Contact support", + // Customer Center – default survey + "customer_center_survey_cancel_title": "Why are you cancelling?", + "customer_center_survey_too_expensive": "Too expensive", + "customer_center_survey_dont_use": "Don't use the app", + "customer_center_survey_bought_by_mistake": "Bought by mistake", + // Customer Center – purchase status + "customer_center_renews_on_for": "Renews on %@ for %@", + "customer_center_renews_on": "Renews on %@", + "customer_center_expires_on": "Expires on %@", + "customer_center_expired_on": "Expired on %@", + "customer_center_free_trial_until": "Free trial until %@", + "customer_center_billing_issue": "Billing issue – update your payment method to keep access", + "customer_center_lifetime": "Lifetime access", + "customer_center_revoked": "Refunded", + "customer_center_purchased_on": "Purchased on %@", + "customer_center_active_via_superwall": "Active", + "customer_center_price_per_period": "%@ / %@", + "customer_center_expired": "Expired", + // Customer Center – badges + "customer_center_badge_active": "Active", + "customer_center_badge_free_trial": "Free trial", + "customer_center_badge_cancelled": "Cancelled", + "customer_center_badge_billing_issue": "Billing issue", + "customer_center_badge_expired": "Expired", + "customer_center_badge_revoked": "Refunded", + "customer_center_badge_lifetime": "Lifetime", + // Customer Center – stores + "customer_center_store_web": "Web", + "customer_center_store_google_play": "Google Play", + "customer_center_store_superwall": "Superwall", + "customer_center_store_other": "Other", + "customer_center_family_shared": "Shared through Family Sharing", + // Customer Center – sections + "customer_center_section_subscriptions": "Subscriptions", + "customer_center_section_purchases": "Purchases", + "customer_center_section_actions": "Actions", + "customer_center_account_details": "Account details", + "customer_center_user_id": "User ID", + "customer_center_copy": "Copy", + "customer_center_copied": "Copied", + "customer_center_original_download_date": "Original download date", + // Customer Center – restore + "customer_center_restoring": "Restoring…", + "customer_center_restore_success_title": "Purchases restored", + "customer_center_restore_success_message": "We restored your past purchases and applied them to your account.", + "customer_center_restore_none_title": "No past purchases", + "customer_center_restore_none_message": "We couldn't find any purchases for your account. If you think this " + + "is an error, please contact support.", + // Customer Center – refund + "customer_center_refund_success": "Apple has received your refund request.", + "customer_center_refund_error": "Something went wrong requesting a refund. Please try again.", + // Customer Center – update warning + "customer_center_update_title": "Update available", + "customer_center_update_message": "Downloading the latest version of the app may help solve the problem.", + "customer_center_update_action": "Update", + "customer_center_update_continue": "Continue", + // Customer Center – duplicate subscriptions + "customer_center_duplicate_title": "You may have duplicate subscriptions", + "customer_center_duplicate_message": "You might be subscribed both on the web and through the App Store. To " + + "avoid being charged twice, cancel one of them.", + // Customer Center – support + "customer_center_support_subject": "Support request", + "customer_center_support_body": "Please describe your issue or question.", + "customer_center_no_mail_app": "No mail app is configured on this device. You can reach us at %@." +] diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift new file mode 100644 index 0000000000..48d3e4c3ef --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -0,0 +1,225 @@ +// +// CustomerCenterView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +/// Navigation behaviour of ``CustomerCenterView``. +@available(iOS 15.0, *) +public struct CustomerCenterNavigationOptions { + /// `true` when you push the view inside your own navigation stack (no wrapping `NavigationView`). + public var usesExistingNavigation: Bool + /// Shows a close button in the trailing toolbar position. + public var showsCloseButton: Bool + /// Called when the close button is tapped. `nil` uses the environment dismiss action. + public var onClose: (() -> Void)? + + /// Creates navigation options for ``CustomerCenterView``. + /// - Parameters: + /// - usesExistingNavigation: `true` when you push the view inside your own navigation stack. + /// - showsCloseButton: Shows a close button in the trailing toolbar position. + /// - onClose: Called when the close button is tapped. `nil` uses the environment dismiss action. + public init( + usesExistingNavigation: Bool = false, + showsCloseButton: Bool = true, + onClose: (() -> Void)? = nil + ) { + self.usesExistingNavigation = usesExistingNavigation + self.showsCloseButton = showsCloseButton + self.onClose = onClose + } + + /// The default navigation options: wraps in its own `NavigationView` and shows a close button. + public static let `default` = CustomerCenterNavigationOptions() +} + +/// A self-service screen where users can view and manage their subscriptions and purchases. +@available(iOS 15.0, *) +public struct CustomerCenterView: View { + @StateObject private var viewModel: CustomerCenterViewModel + private let navigationOptions: CustomerCenterNavigationOptions + /// Supplied when the host owns the navigation, so drill-downs push onto their stack. + private let navigator: CustomerCenterNavigating? + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.customerCenterCallbacks) private var callbacksBox + + /// Creates a Customer Center view. + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter``. `nil` uses the options value. + /// - navigationOptions: How the view integrates with navigation. + public init( + configuration: CustomerCenterConfiguration? = nil, + navigationOptions: CustomerCenterNavigationOptions = .default + ) { + // `StateObject(wrappedValue:)` takes an `@autoclosure`, so inlining the construction call + // directly into the argument defers it until SwiftUI installs the state object for the + // first time. Building the model in a local `let` first would rebuild it on every + // `CustomerCenterView.init` (i.e. on every parent body evaluation) and throw it away. + _viewModel = StateObject( + wrappedValue: Self.makeConfiguredViewModel( + configuration: configuration, + usesExistingNavigation: navigationOptions.usesExistingNavigation + ) + ) + self.navigationOptions = navigationOptions + navigator = nil + } + + private static func makeConfiguredViewModel( + configuration: CustomerCenterConfiguration?, + usesExistingNavigation: Bool + ) -> CustomerCenterViewModel { + let model = CustomerCenterManager.makeViewModel(configuration: configuration) + model.presentationMode = usesExistingNavigation ? "embedded" : "sheet" + return model + } + + init( + viewModel: CustomerCenterViewModel, + navigationOptions: CustomerCenterNavigationOptions, + navigator: CustomerCenterNavigating? = nil + ) { + _viewModel = StateObject(wrappedValue: viewModel) + self.navigationOptions = navigationOptions + self.navigator = navigator + } + + public var body: some View { + Group { + if navigationOptions.usesExistingNavigation { + content + } else { + NavigationView { content }.navigationViewStyle(.stack) + } + } + .environment(\.customerCenterStrings, viewModel.strings) + .environment(\.customerCenterTheme, theme) + .environment(\.customerCenterNavigator, navigator) + .task { + viewModel.callbacks = Self.merged(viewModel.callbacks, callbacksBox.callbacks) + await viewModel.load() + } + // Part of the visibility count that determines when the Customer Center has genuinely + // closed — see `CustomerCenterViewModel.surfaceDidAppear()`. + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } + } + + /// Combines the view model's existing callbacks (e.g. set by the UIKit adapter) with those + /// accumulated in the environment by `.onCustomerCenter*` modifiers, preferring the + /// environment's non-nil closures for each field. Not `private` so it stays directly testable; + /// it's still excluded from the SDK's public interface. + static func merged( + _ existing: CustomerCenterCallbacks, + _ environment: CustomerCenterCallbacks + ) -> CustomerCenterCallbacks { + var result = existing + result.shouldRestore = environment.shouldRestore ?? existing.shouldRestore + result.didSelectAction = environment.didSelectAction ?? existing.didSelectAction + result.didCompleteSurvey = environment.didCompleteSurvey ?? existing.didCompleteSurvey + result.didCompleteRefund = environment.didCompleteRefund ?? existing.didCompleteRefund + result.didDismiss = environment.didDismiss ?? existing.didDismiss + return result + } + + private var content: some View { + screenContent + .customerCenterSheets(viewModel: viewModel) + .tint(themeAccent) + } + + // `ToolbarContentBuilder`'s conditional (`if`) support needs iOS 16, so the close button is + // toggled here at the plain `@ViewBuilder` level instead, which iOS 15 supports. Branching on + // the flag is safe even though branch flips tear down modifiers: it comes from + // `navigationOptions`, fixed for the view's lifetime, so it can't flip mid-update. + @ViewBuilder + private var screenContent: some View { + if navigationOptions.showsCloseButton { + coreContent.toolbar { closeButtonToolbarItem } + } else { + coreContent + } + } + + private var coreContent: some View { + ZStack { + // `.identity` so the content doesn't animate: it belongs to the same transaction as the + // cover's removal, and without this it would take SwiftUI's default opacity transition and + // fade in while the cover fades out. The content should simply be there, revealed. + loadedContent.transition(.identity) + RestoreOverlay(viewModel: viewModel) + } + // The spinner covers the screen rather than standing in for it, so the content isn't built + // and swapped underneath the user — it's simply revealed as the cover fades. The animation + // lives here, on the view that owns the condition, because that's what supplies the + // transaction the cover's removal transition runs in. + .overlay(loadingCover) + .animation(loadingCoverAnimation, value: viewModel.state) + } + + @ViewBuilder + private var loadedContent: some View { + switch viewModel.state { + case .loading: + // Nothing yet — whether this becomes the management or the no-purchases screen isn't known + // until the load finishes, and guessing would show the wrong one for a frame. + Color.clear + case .management: + ManagementScreenView(viewModel: viewModel) + case .noPurchases: + NoPurchasesScreenView(viewModel: viewModel) + } + } + + /// The cover's fade. Applied to the container rather than the cover itself: a modifier on the + /// departing view isn't what drives its removal transition. + private var loadingCoverAnimation: Animation? { + reduceMotion ? nil : .easeOut(duration: 0.24) + } + + @ViewBuilder + private var loadingCover: some View { + if viewModel.state == .loading { + ZStack { + // Opaque, so nothing shows through and no touch reaches a half-built screen. Matches the + // `insetGrouped` list it becomes. + // + // Deliberately not driven by a configured background colour: `Appearance` no longer + // exposes one, and honouring it here alone would tint the cover and then fade to an + // untinted list — a colour flip on exactly the screens that configured a background. + // It belongs here when background theming is wired up across the screens, not before. + Color(uiColor: .systemGroupedBackground) + ProgressView().accessibilityIdentifier("customer_center.loading") + } + // Edge to edge, because the `List` this becomes already runs under the navigation bar and + // past the home indicator with the same background. Confining the cover to the safe area + // leaves it floating in a frame of unpainted screen, and the region it covers ends up the + // same colour either way — so there's no bar being flattened, only continuity. + .ignoresSafeArea() + .transition(.opacity) + } + } + + private var closeButtonToolbarItem: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + if let onClose = navigationOptions.onClose { onClose() } else { dismiss() } + } label: { + Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary) + } + .accessibilityLabel(viewModel.strings.string("customer_center_close")) + .accessibilityIdentifier("customer_center.close") + } + } + + private var theme: CustomerCenterTheme { + CustomerCenterTheme(appearance: viewModel.configuration.appearance, colorScheme: colorScheme) + } + + private var themeAccent: Color? { theme.accent } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift b/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift new file mode 100644 index 0000000000..51bb86f61e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift @@ -0,0 +1,24 @@ +// +// DuplicateSubscriptionBanner.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct DuplicateSubscriptionBanner: View { + @Environment(\.customerCenterStrings) private var strings + var body: some View { + Section { + VStack(alignment: .leading, spacing: 6) { + Label(strings.string("customer_center_duplicate_title"), systemImage: "exclamationmark.triangle.fill") + .font(.headline) + Text(strings.string("customer_center_duplicate_message")).font(.subheadline).foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityIdentifier("customer_center.duplicate_warning") + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift b/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift new file mode 100644 index 0000000000..bd41c6f564 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift @@ -0,0 +1,56 @@ +// +// FeedbackSurveyView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct FeedbackSurveyView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + @State private var answering: String? + + var body: some View { + NavigationView { + List { + if let survey = viewModel.pendingSurvey?.survey { + ForEach(survey.options, id: \.id) { option in + Button { + guard answering == nil else { return } + answering = option.id + Task { await viewModel.answerSurvey(optionId: option.id) } + } label: { + HStack { Text(optionTitle(option)); Spacer(); if answering == option.id { ProgressView() } } + } + .disabled(answering != nil) + .accessibilityIdentifier("customer_center.survey.option.\(option.id)") + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(viewModel.pendingSurvey?.survey.title ?? strings.string("customer_center_survey_cancel_title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(strings.string("customer_center_cancel")) { viewModel.cancelSurvey() } + .accessibilityIdentifier("customer_center.survey.cancel") + } + } + } + .navigationViewStyle(.stack) + .interactiveDismissDisabled(answering != nil) + } + + private func optionTitle(_ option: CustomerCenterConfiguration.FeedbackSurvey.Option) -> String { + if let title = option.title { return title } + switch option.id { + case "too_expensive": return strings.string("customer_center_survey_too_expensive") + case "dont_use": return strings.string("customer_center_survey_dont_use") + case "bought_by_mistake": return strings.string("customer_center_survey_bought_by_mistake") + default: return option.id + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift new file mode 100644 index 0000000000..e3a441acf7 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -0,0 +1,114 @@ +// +// ManagementScreenView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct ManagementScreenView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var subscriptions: [PurchasePresentation] { viewModel.purchases.filter(\.opensDetail) } + private var others: [PurchasePresentation] { viewModel.purchases.filter { !$0.opensDetail } } + + var body: some View { + List { + if viewModel.showsUpdateBanner { + AppUpdateWarningView(viewModel: viewModel) + } + if viewModel.showsDuplicateBanner { + DuplicateSubscriptionBanner() + } + if !subscriptions.isEmpty { + // Every subscription — and every entitlement-only purchase, see `opensDetail` — is a row + // that opens its own detail screen, one or many alike. This screen keeps the actions that + // apply to the account; anything that only makes sense against one purchase — change + // plan, refund, cancel, the web management page — lives where the row leads. + Section(strings.string("customer_center_section_subscriptions")) { + ForEach(subscriptions) { purchase in + CustomerCenterDrillDown { + PurchaseDetailScreenView(viewModel: viewModel, purchase: purchase) + } label: { + PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) + } + } + } + } + if !others.isEmpty { + Section(strings.string("customer_center_section_purchases")) { + ForEach(others) { PurchaseCardView(purchase: $0, refundResult: nil) } + } + } + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: nil) + } + if viewModel.configuration.showsAccountDetails { + AccountDetailsSection(viewModel: viewModel) + } + } + .listStyle(.insetGrouped) + // The update banner can arrive a beat after the screen does — its version comes from an App + // Store lookup — so animate the insertion rather than letting a row appear from nowhere. + .animation(reduceMotion ? nil : .easeInOut(duration: 0.25), value: viewModel.showsUpdateBanner) + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + } + + private var navigationTitle: String { + viewModel.configuration.managementScreen.title ?? strings.string("customer_center_management_title") + } +} + +/// Detail for one subscription, reached by tapping its row on the management screen. Carries the +/// actions that only make sense against that subscription. +@available(iOS 15.0, *) +struct PurchaseDetailScreenView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + let purchase: PurchasePresentation + @Environment(\.customerCenterStrings) private var strings + + var body: some View { + List { + Section { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) } + if let empty = viewModel.detailEmptyState(for: purchase) { + // The row opened this screen regardless — see `detailEmptyState(for:)` — so say what + // there is to say rather than head an empty list with "Actions". Which sentence depends + // on the purchase: a subscription the customer is still paying for, from a store this + // SDK can't drive, is told where to manage it; only a purchase with genuinely nothing + // left to do is told that. + Section { + Text(emptyStateText(empty)) + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibilityIdentifier("customer_center.detail.nothing_to_manage") + } + } else { + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(purchase.title ?? "") + .navigationBarTitleDisplayMode(.inline) + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } + } + + private func emptyStateText(_ state: CustomerCenterViewModel.DetailEmptyState) -> String { + switch state { + case .nothingToDo: + return strings.string("customer_center_detail_nothing_to_manage") + case .managedElsewhere(let storeLabelKey): + guard let storeLabelKey else { + return strings.string("customer_center_detail_managed_where_bought") + } + return strings.string("customer_center_detail_managed_through", strings.string(storeLabelKey)) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift new file mode 100644 index 0000000000..2d78b523e4 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift @@ -0,0 +1,35 @@ +// +// NoPurchasesScreenView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct NoPurchasesScreenView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + + var body: some View { + List { + Section { + let screen = viewModel.configuration.noPurchasesScreen + VStack(alignment: .leading, spacing: 6) { + Text(screen.title ?? strings.string("customer_center_no_purchases_title")) + .font(.headline) + Text(screen.subtitle ?? strings.string("customer_center_no_purchases_subtitle")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityIdentifier("customer_center.no_purchases") + } + Section { PathsListView(viewModel: viewModel, purchase: nil) } + if viewModel.configuration.showsAccountDetails { AccountDetailsSection(viewModel: viewModel) } + } + .listStyle(.insetGrouped) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift new file mode 100644 index 0000000000..82dc305fec --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -0,0 +1,72 @@ +// +// PathsListView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct PathsListView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + let purchase: PurchasePresentation? + /// `true` for a screen's main action list; `false` on a drilled-in purchase detail screen. + var isScreenLevel = true + @Environment(\.customerCenterStrings) private var strings + @State private var loadingPathId: String? + + var body: some View { + ForEach(viewModel.paths(for: purchase, isScreenLevel: isScreenLevel)) { resolved in + Button { + guard loadingPathId == nil else { return } + loadingPathId = resolved.id + Task { + await viewModel.select(resolved, purchase: purchase) + loadingPathId = nil + } + } label: { + // No disclosure chevron: a chevron promises a push onto the navigation stack, and every + // path here either presents a sheet, acts in place, or leaves the app. The rows that do + // push — the per-subscription detail rows — are `NavigationLink`s and get their chevron + // from SwiftUI. + HStack { + Text(title(for: resolved)) + Spacer() + if loadingPathId == resolved.id { + ProgressView() + } + } + } + .disabled(loadingPathId != nil) + .accessibilityIdentifier("customer_center.path.\(resolved.id)") + } + } + + private func title(for resolved: ResolvedPath) -> String { + Self.title(for: resolved, strings: strings) + } + + /// What a row says. Static so the rule can be exercised directly: a title that reads the same + /// for two different paths is a bug you can only see by comparing two of them, which a rendered + /// view can't easily be asked about. + static func title(for resolved: ResolvedPath, strings: CustomerCenterStrings) -> String { + let path = resolved.path + if let title = path.title { return title } + switch path.type { + case .restore: return strings.string("customer_center_path_restore") + case .manageSubscription: + // "Cancel subscription" is right for the App Store path, where the row carries the + // cancellation survey and opens Apple's cancel sheet. A web management page does more than + // cancel, so naming it that way there undersells it. + return resolved.destination.isWebManagement + ? strings.string("customer_center_path_manage_subscription_web") + : strings.string("customer_center_path_manage_subscription") + case .refund: return strings.string("customer_center_path_refund") + case .changePlan: return strings.string("customer_center_path_change_plan") + case .contactSupport: return strings.string("customer_center_path_contact_support") + case .url(_, let title, _): return title + case .custom(let identifier): return identifier + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift new file mode 100644 index 0000000000..1db80a35d7 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift @@ -0,0 +1,79 @@ +// +// PurchaseCardView.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct PurchaseCardView: View { + let purchase: PurchasePresentation + let refundResult: (productId: String, status: CustomerCenterRefundStatus)? + @Environment(\.customerCenterStrings) private var strings + + var body: some View { + // The badge sits beside the text column rather than on a row of its own, so a card with no + // title — a web product the catalogue hasn't named — doesn't open with an empty line. The + // purchase is never hidden; only its title is allowed to be absent. + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + if let title = purchase.title { Text(title).font(.headline) } + if let price = purchase.priceLine { Text(price).font(.subheadline) } + Text(purchase.statusLine).font(.subheadline).foregroundStyle(.secondary) + if let key = purchase.storeLabelKey { + Text(strings.string(key)).font(.caption).foregroundStyle(.secondary) + } + if let refundResult, refundResult.productId == purchase.productId { + let isSuccess = refundResult.status == .success + Text(strings.string(isSuccess ? "customer_center_refund_success" : "customer_center_refund_error")) + .font(.caption) + .foregroundStyle(isSuccess ? Color.green : Color.red) + } + } + Spacer(minLength: 8) + BadgeView(badge: purchase.badge, rowId: purchase.productId ?? purchase.id) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("customer_center.purchase.\(purchase.productId ?? purchase.id)") + } +} + +@available(iOS 15.0, *) +struct BadgeView: View { + let badge: PurchaseBadge + var rowId: String? + @Environment(\.customerCenterStrings) private var strings + + private var key: String { + switch badge { + case .active: return "customer_center_badge_active" + case .freeTrial: return "customer_center_badge_free_trial" + case .cancelled: return "customer_center_badge_cancelled" + case .billingIssue: return "customer_center_badge_billing_issue" + case .expired: return "customer_center_badge_expired" + case .revoked: return "customer_center_badge_revoked" + case .lifetime: return "customer_center_badge_lifetime" + } + } + private var color: Color { + switch badge { + case .active, .lifetime: return .green + case .freeTrial: return .orange + case .cancelled, .billingIssue, .revoked: return .red + case .expired: return .gray + } + } + var body: some View { + Text(strings.string(key)) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(color.opacity(0.15)) + .foregroundStyle(color) + .clipShape(Capsule()) + .accessibilityIdentifier(rowId.map { "customer_center.badge.\(key).\($0)" } ?? "customer_center.badge.\(key)") + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift new file mode 100644 index 0000000000..8c63e680c0 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift @@ -0,0 +1,64 @@ +// +// RestoreOverlay.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct RestoreOverlay: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + @Environment(\.openURL) private var openURL + + var body: some View { + ZStack { + if viewModel.restoreState == .restoring { + Color.black.opacity(0.25).ignoresSafeArea() + VStack(spacing: 12) { + ProgressView() + Text(strings.string("customer_center_restoring")).font(.footnote) + } + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) + .accessibilityIdentifier("customer_center.restoring") + } + } + .animation(.default, value: viewModel.restoreState) + .alert( + strings.string(alertTitleKey), + isPresented: Binding( + get: { viewModel.restoreState == .restored || viewModel.restoreState == .notFound }, + set: { if !$0 { viewModel.restoreState = .idle } } + ), + actions: { + if viewModel.restoreState == .notFound { + if viewModel.showsUpdateBanner, let url = viewModel.appStoreURL { + Button(strings.string("customer_center_update_action")) { openURL(url) } + } + if let mail = viewModel.supportMailtoURL { + Button(strings.string("customer_center_path_contact_support")) { openURL(mail) } + } + } + Button(strings.string("customer_center_done"), role: .cancel) {} + }, + message: { + Text(strings.string(alertMessageKey)) + } + ) + } + + private var alertTitleKey: String { + viewModel.restoreState == .restored + ? "customer_center_restore_success_title" + : "customer_center_restore_none_title" + } + + private var alertMessageKey: String { + viewModel.restoreState == .restored + ? "customer_center_restore_success_message" + : "customer_center_restore_none_message" + } +} diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba96227197..e168b42c2a 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -48,6 +48,28 @@ final class DependencyContainer { // swiftlint:enable implicitly_unwrapped_optional let paywallArchiveManager = PaywallArchiveManager() + // `CustomerCenterManager` is `@available(iOS 15.0, *)`, and stored properties can't carry an + // availability attribute, so the typed accessor below is backed by an untyped `Any?`. + private var _customerCenterManager: Any? + + /// Builds the dependencies backing the Customer Center and owns its presentation state. + /// + /// Built lazily on first access rather than in `init`, since `DependencyContainer.init` is not + /// itself main-actor-isolated (many test suites, and potentially host apps, construct it off the + /// main thread), while `CustomerCenterManager` is `@MainActor`. All production call sites + /// (`Superwall.presentCustomerCenter`/`dismissCustomerCenter`/the Objective-C variant) are + /// themselves `@MainActor`, so this accessor is only ever reached from the main actor. + @available(iOS 15.0, *) + @MainActor + var customerCenterManager: CustomerCenterManager { + if let manager = _customerCenterManager as? CustomerCenterManager { + return manager + } + let manager = CustomerCenterManager(container: self) + _customerCenterManager = manager + return manager + } + init( apiKey: String = "", purchaseController controller: PurchaseController? = nil, diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md new file mode 100644 index 0000000000..9508e5d2a2 --- /dev/null +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -0,0 +1,239 @@ +# Customer Center + +A native, self-service screen where users can view and manage their subscriptions and purchases. + +## Overview + +The Customer Center lets users restore purchases, manage or cancel a subscription, request a +refund, change plans, contact support, answer exit surveys and open any subscription for its details — all +without leaving your app. It ships with sensible defaults and is fully configurable, so you can +tailor which paths appear, their titles, surveys and accent colour to match your app. + +The Customer Center requires **iOS 15.0+**. + +### Presenting from UIKit + +Present it over your current view controller with ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``: + +```swift +Superwall.shared.presentCustomerCenter() +``` + +Or use ``CustomerCenterViewController`` yourself. Present it modally: + +```swift +let customerCenter = CustomerCenterViewController(delegate: myDelegate) +present(customerCenter, animated: true) +``` + +Or push it onto a navigation controller of your own, which is what you want when the Customer +Center is a row in your own settings screen: + +```swift +let customerCenter = CustomerCenterViewController( + presentationStyle: .pushed, + delegate: myDelegate +) +navigationController?.pushViewController(customerCenter, animated: true) +``` + +A pushed Customer Center renders into your navigation bar and leaves it alone — your title, your +back button, your appearance, your swipe-to-go-back. It adds no close button, since your stack +already provides the way back. Its own screen — the detail for a subscription — is pushed onto +your stack as a further view controller, so it behaves like any other screen you pushed yourself. + +> Important: A `CustomerCenterViewController` you construct yourself is yours, and the SDK does not +> track it. ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)`` will present +> a second, independent Customer Center over the top of one you pushed, and +> ``Superwall/dismissCustomerCenter(completion:)`` only dismisses the one the SDK presented — it +> does nothing to yours. Pick one entry point per screen: let the SDK present it, or own the +> lifecycle of the controller you construct. + +### Presenting from SwiftUI + +Use the ``SwiftUICore/View/presentSuperwallCustomerCenter(isPresented:configuration:onDismiss:)`` modifier to present it as a sheet: + +```swift +struct SettingsView: View { + @State private var showsCustomerCenter = false + + var body: some View { + Button("Manage Subscription") { + showsCustomerCenter = true + } + .presentSuperwallCustomerCenter(isPresented: $showsCustomerCenter) + } +} +``` + +Or embed ``CustomerCenterView`` directly in your own navigation stack: + +```swift +CustomerCenterView(navigationOptions: .init(usesExistingNavigation: true)) +``` + +### Presenting from Objective-C + +Use `presentCustomerCenterWithConfiguration:from:delegate:onDismiss:`: + +```objc +[Superwall.sharedInstance presentCustomerCenterWithConfiguration:nil + from:nil + delegate:myDelegate + onDismiss:nil]; +``` + +## Configuring the Customer Center + +Set the default configuration via ``SuperwallOptions/customerCenter`` before calling +`Superwall.configure(apiKey:purchaseController:options:completion:)`, or pass a +``CustomerCenterConfiguration`` directly to a presentation call to override it for that +presentation only. + +```swift +let options = SuperwallOptions() + +let cancelSurvey = CustomerCenterConfiguration.FeedbackSurvey( + id: "cancel_survey", + title: "Why are you cancelling?", + options: [ + .init(id: "too_expensive", title: "Too expensive"), + .init(id: "dont_use", title: "Don't use it enough"), + .init(id: "bought_by_mistake", title: "Bought by mistake") + ] +) + +options.customerCenter = CustomerCenterConfiguration( + managementScreen: .init( + paths: [ + .init(id: "restore", type: .restore), + .init(id: "change_plan", type: .changePlan()), + .init(id: "refund", type: .refund()), + .init(id: "manage_subscription", type: .manageSubscription, survey: cancelSurvey), + .init(id: "faq", type: .url(URL(string: "https://mycompany.com/faq")!, title: "FAQ", openMethod: .inApp)), + .init(id: "contact_support", type: .contactSupport) + ] + ), + noPurchasesScreen: .init( + paths: [.init(id: "restore", type: .restore)] + ), + support: .init(email: "support@mycompany.com") +) + +Superwall.configure(apiKey: "MY_API_KEY", options: options) +``` + +Every path is optional and reorderable. Built-in path types (``CustomerCenterConfiguration/PathType``) +cover restoring purchases, managing or cancelling a subscription, requesting a refund, changing +plans, and contacting support; ``CustomerCenterConfiguration/PathType/url(_:title:openMethod:)`` +opens a URL either in-app or externally, and ``CustomerCenterConfiguration/PathType/custom(identifier:)`` +lets you handle an action entirely yourself via the delegate. + +Every path type but `url` names its own row, so `title` on ``CustomerCenterConfiguration/Path`` is +optional and overrides that default. `url` takes a title of its own because there is no sensible +default: a URL could be anything, and its host is the same across all of your own links, so +deriving one would render your FAQ, terms and privacy rows identically. + +### Warning customers about old versions + +The Customer Center can show a banner asking the customer to update. By default it finds the +published version itself, by looking your app up on the App Store: + +```swift +options.customerCenter.support = .init( + email: "support@mycompany.com", + shouldWarnToUpdate: true // on by default +) +``` + +Set `latestAppVersion` to skip the lookup and warn against a version you control, which is what +you want if you gate support on a specific build: + +```swift +options.customerCenter.support = .init( + email: "support@mycompany.com", + latestAppVersion: "2.1.0" +) +``` + +The banner appears only when the installed version is *older* than the published one — never when +it merely differs. It is skipped entirely on TestFlight, sandbox and simulator builds, whose +version is normally ahead of the App Store. Set `checksAppStoreForUpdates` to `false` to stop the +lookup without turning the banner off. Any failure — offline, no listing found, an unparseable +version — hides the banner and logs under the `customerCenter` scope. + +> Note: The lookup result is cached for 24 hours, and only the bundle identifier is sent. Because +> it happens after the screen has loaded, the banner animates in a moment later rather than being +> there on first paint. + +> Warning: Apple phases a release in over seven days, but the lookup sees the new version as soon +> as it goes live. For the first few days of a release, some customers are told to update to a +> build that hasn't reached them yet. If that matters for your app, set `latestAppVersion` and +> raise it on your own schedule, or set `checksAppStoreForUpdates` to `false`. + +## The Delegate + +Implement ``CustomerCenterDelegate`` (or ``CustomerCenterDelegateObjc`` from Objective-C) to +observe and, where relevant, gate what happens in the Customer Center: + +```swift +final class MyCustomerCenterDelegate: CustomerCenterDelegate { + func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) { + resume(true) + } + + func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) { + print("Customer Center action selected: \(action)") + } + + func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) { + print("Survey \(surveyId) answered with \(optionId)") + } + + func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) { + print("Refund request for \(productId) finished with status \(status)") + } + + func customerCenterDidDismiss() { + print("Customer Center dismissed") + } +} +``` + +The view controller does not retain its delegate — either keep a strong reference to it yourself, +or pass it to `presentCustomerCenter(delegate:)`, which retains it for the duration of the +presentation. + +In SwiftUI, use the equivalent modifiers instead of a delegate: + +```swift +CustomerCenterView() + .onCustomerCenterShouldRestore { resume in resume(true) } + .onCustomerCenterAction { action, purchase in print(action) } + .onCustomerCenterSurveyResponse { surveyId, optionId, action in print(surveyId, optionId) } + .onCustomerCenterRefundRequest { productId, status in print(productId, status) } + .onCustomerCenterDismiss { print("dismissed") } +``` + +## Events + +The Customer Center fires the following ``SuperwallEvent`` cases, which you can observe via +``SuperwallDelegate/handleSuperwallEvent(withInfo:)`` alongside all other SDK events: + +- `customerCenterOpen`: the Customer Center is presented. +- `customerCenterClose`: the Customer Center is dismissed. +- `customerCenterAction`: the user taps a path. +- `customerCenterSurveyResponse`: the user answers a survey attached to a path. +- `customerCenterRefundRequest`: a refund request finishes. + +## Limitations + +- Requires iOS 15.0+. On earlier versions, presentation calls are unavailable at compile time. +- A purchase whose product has no display name is headed by the entitlement it unlocks, or by + nothing — never by the product identifier. Today that is every web (Stripe, Paddle) purchase, + because the product catalogue doesn't return a name yet; the name is used as soon as it does. + An App Store product has one once it is localized in App Store Connect — before that, StoreKit + reports an empty display name, which is treated the same way. +- Promotional offers are not yet supported as a Customer Center path. +- Remote configuration of the Customer Center from the Superwall dashboard is coming; today it's + configured entirely in code via ``SuperwallOptions/customerCenter``. diff --git a/Sources/SuperwallKit/Documentation.docc/SuperwallKit.md b/Sources/SuperwallKit/Documentation.docc/SuperwallKit.md index 9ee8290865..26f447ce9c 100644 --- a/Sources/SuperwallKit/Documentation.docc/SuperwallKit.md +++ b/Sources/SuperwallKit/Documentation.docc/SuperwallKit.md @@ -19,3 +19,9 @@ See our [docs](https://docs.superwall.com/docs) for more information. - `SuperwallDelegate` - `PurchaseController` + +### Customer Center + +- +- `CustomerCenterConfiguration` +- `CustomerCenterDelegate` diff --git a/Sources/SuperwallKit/Logger/LogScope.swift b/Sources/SuperwallKit/Logger/LogScope.swift index 07e74045cb..38ae00b017 100644 --- a/Sources/SuperwallKit/Logger/LogScope.swift +++ b/Sources/SuperwallKit/Logger/LogScope.swift @@ -34,6 +34,7 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { case cache case webEntitlements case all + case customerCenter public var description: String { switch self { @@ -85,6 +86,8 @@ public enum LogScope: Int, Encodable, Sendable, CustomStringConvertible { return "webEntitlements" case .all: return "all" + case .customerCenter: + return "customerCenter" } } } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 968fea372d..7ca78bdad4 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.16.4 +4.17.0 """ diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift index c59f84159b..87a8d55041 100644 --- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift +++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift @@ -679,6 +679,9 @@ class DeviceHelper { return installDate }() + /// The device's app install date, exposed internally for consumers such as the Customer Center. + var appInstallDateValue: Date? { appInstallDate } + private let sdkVersionPadded: String private let appVersionPadded: String diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index 07b98f8011..ad166192a5 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -21,6 +21,14 @@ public struct SuperwallProduct: Decodable, Sendable { /// The product identifier (e.g., App Store product ID). public let identifier: String + /// The product's display name. + /// + /// `nil` today: `/v1/products` doesn't return a name yet, so anything showing a web product + /// falls back to its identifier. Populated automatically once the payload carries `name`. + /// A `var` rather than a `let` purely so the memberwise initializer defaults it to `nil`, + /// leaving existing construction sites untouched. + public var name: String? + /// The platform this product is for. public let platform: SuperwallProductPlatform diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 06a6fb3289..5d9ee29690 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "تم"; + +/* Customer Center – screens */ +"customer_center_management_title" = "إدارة اشتراكك"; +"customer_center_no_purchases_title" = "لم يتم العثور على اشتراكات"; +"customer_center_no_purchases_subtitle" = "يمكننا التحقق من عمليات الشراء السابقة."; +"customer_center_close" = "إغلاق"; +"customer_center_done" = "تم"; +"customer_center_cancel" = "إلغاء"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "استعادة المشتريات"; +"customer_center_path_manage_subscription" = "إلغاء الاشتراك"; +"customer_center_path_manage_subscription_web" = "إدارة الاشتراك"; +"customer_center_web_manage_unavailable" = "أدر اشتراكك عبر الرابط الموجود في إيصال البريد الإلكتروني."; +"customer_center_path_refund" = "طلب استرداد الأموال"; +"customer_center_path_change_plan" = "تغيير الخطة"; +"customer_center_path_contact_support" = "التواصل مع الدعم"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "لماذا تقوم بالإلغاء؟"; +"customer_center_survey_too_expensive" = "باهظ الثمن"; +"customer_center_survey_dont_use" = "لا أستخدم التطبيق"; +"customer_center_survey_bought_by_mistake" = "تم الشراء عن طريق الخطأ"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "يتجدد في %@ مقابل %@"; +"customer_center_renews_on" = "يتجدد في %@"; +"customer_center_expires_on" = "تنتهي الصلاحية في %@"; +"customer_center_expired_on" = "انتهت الصلاحية في %@"; +"customer_center_free_trial_until" = "فترة تجريبية مجانية حتى %@"; +"customer_center_billing_issue" = "مشكلة في الفوترة – يرجى تحديث طريقة الدفع للحفاظ على الوصول"; +"customer_center_lifetime" = "وصول مدى الحياة"; +"customer_center_revoked" = "تم استرداد المبلغ"; +"customer_center_purchased_on" = "تم الشراء في %@"; +"customer_center_active_via_superwall" = "نشط"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "منتهي الصلاحية"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "نشط"; +"customer_center_badge_free_trial" = "فترة تجريبية مجانية"; +"customer_center_badge_cancelled" = "تم الإلغاء"; +"customer_center_badge_billing_issue" = "مشكلة في الفوترة"; +"customer_center_badge_expired" = "منتهي الصلاحية"; +"customer_center_badge_revoked" = "تم استرداد المبلغ"; +"customer_center_badge_lifetime" = "مدى الحياة"; + +/* Customer Center – stores */ +"customer_center_store_web" = "الويب"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "أخرى"; +"customer_center_family_shared" = "مشترك عبر مشاركة العائلة"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "الاشتراكات"; +"customer_center_section_purchases" = "المشتريات"; +"customer_center_section_actions" = "الإجراءات"; +"customer_center_account_details" = "تفاصيل الحساب"; +"customer_center_user_id" = "معرّف المستخدم"; +"customer_center_copy" = "نسخ"; +"customer_center_copied" = "تم النسخ"; +"customer_center_original_download_date" = "تاريخ التنزيل الأصلي"; + +/* Customer Center – restore */ +"customer_center_restoring" = "جارٍ الاستعادة…"; +"customer_center_restore_success_title" = "تمت استعادة المشتريات"; +"customer_center_restore_success_message" = "لقد استعدنا مشترياتك السابقة وطبّقناها على حسابك."; +"customer_center_restore_none_title" = "لا توجد مشتريات سابقة"; +"customer_center_restore_none_message" = "لم نتمكن من العثور على أي مشتريات لحسابك. إذا كنت تعتقد أن هذا خطأ، يرجى التواصل مع الدعم."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "استلمت Apple طلب استرداد الأموال الخاص بك."; +"customer_center_refund_error" = "حدث خطأ ما أثناء طلب استرداد الأموال. يرجى المحاولة مرة أخرى."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "يتوفر تحديث"; +"customer_center_update_message" = "قد يساعد تنزيل أحدث إصدار من التطبيق في حل المشكلة."; +"customer_center_update_action" = "تحديث"; +"customer_center_update_continue" = "متابعة"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "قد يكون لديك اشتراكات مكررة"; +"customer_center_duplicate_message" = "من المحتمل أنك مشترك عبر الويب ومن خلال App Store في آنٍ واحد. لتجنب الدفع مرتين، يرجى إلغاء أحد الاشتراكين."; + +/* Customer Center – support */ +"customer_center_support_subject" = "طلب دعم"; +"customer_center_support_body" = "يرجى وصف مشكلتك أو سؤالك."; +"customer_center_no_mail_app" = "لا يوجد تطبيق بريد مُهيأ على هذا الجهاز. يمكنك التواصل معنا عبر %@."; +"customer_center_detail_nothing_to_manage" = "لا يوجد ما يمكن إدارته لعملية الشراء هذه."; +"customer_center_detail_managed_through" = "يمكنك إدارة هذا الاشتراك عبر %@."; +"customer_center_detail_managed_where_bought" = "يمكنك إدارة هذا الاشتراك من حيث اشتريته."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index 2f797c9929..666cc85269 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fet"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestiona la teva subscripció"; +"customer_center_no_purchases_title" = "No s'ha trobat cap subscripció"; +"customer_center_no_purchases_subtitle" = "Podem comprovar si hi ha compres anteriors."; +"customer_center_close" = "Tanca"; +"customer_center_done" = "Fet"; +"customer_center_cancel" = "Cancel·la"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaura les compres"; +"customer_center_path_manage_subscription" = "Cancel·la la subscripció"; +"customer_center_path_manage_subscription_web" = "Gestiona la subscripció"; +"customer_center_web_manage_unavailable" = "Gestiona la subscripció amb l'enllaç del rebut que has rebut per correu."; +"customer_center_path_refund" = "Sol·licita un reemborsament"; +"customer_center_path_change_plan" = "Canvia el pla"; +"customer_center_path_contact_support" = "Contacta amb l'assistència"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Per què cancel·les?"; +"customer_center_survey_too_expensive" = "És massa car"; +"customer_center_survey_dont_use" = "No faig servir l'aplicació"; +"customer_center_survey_bought_by_mistake" = "Ho vaig comprar per error"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Es renova el %@ per %@"; +"customer_center_renews_on" = "Es renova el %@"; +"customer_center_expires_on" = "Caduca el %@"; +"customer_center_expired_on" = "Va caducar el %@"; +"customer_center_free_trial_until" = "Prova gratuïta fins al %@"; +"customer_center_billing_issue" = "Problema de facturació: actualitza el mètode de pagament per mantenir l'accés"; +"customer_center_lifetime" = "Accés de per vida"; +"customer_center_revoked" = "Reemborsat"; +"customer_center_purchased_on" = "Comprat el %@"; +"customer_center_active_via_superwall" = "Actiu"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Caducat"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Actiu"; +"customer_center_badge_free_trial" = "Prova gratuïta"; +"customer_center_badge_cancelled" = "Cancel·lat"; +"customer_center_badge_billing_issue" = "Problema de facturació"; +"customer_center_badge_expired" = "Caducat"; +"customer_center_badge_revoked" = "Reemborsat"; +"customer_center_badge_lifetime" = "De per vida"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Altres"; +"customer_center_family_shared" = "Compartit mitjançant En família"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscripcions"; +"customer_center_section_purchases" = "Compres"; +"customer_center_section_actions" = "Accions"; +"customer_center_account_details" = "Detalls del compte"; +"customer_center_user_id" = "ID d'usuari"; +"customer_center_copy" = "Copia"; +"customer_center_copied" = "Copiat"; +"customer_center_original_download_date" = "Data de descàrrega original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restaurant…"; +"customer_center_restore_success_title" = "Compres restaurades"; +"customer_center_restore_success_message" = "Hem restaurat les teves compres anteriors i les hem aplicat al teu compte."; +"customer_center_restore_none_title" = "Cap compra anterior"; +"customer_center_restore_none_message" = "No hem trobat cap compra per al teu compte. Si creus que es tracta d'un error, contacta amb l'assistència."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple ha rebut la teva sol·licitud de reemborsament."; +"customer_center_refund_error" = "S'ha produït un error en sol·licitar el reemborsament. Torna-ho a provar."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Actualització disponible"; +"customer_center_update_message" = "Descarregar la versió més recent de l'aplicació pot ajudar a resoldre el problema."; +"customer_center_update_action" = "Actualitza"; +"customer_center_update_continue" = "Continua"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "És possible que tinguis subscripcions duplicades"; +"customer_center_duplicate_message" = "És possible que estiguis subscrit tant a través del web com de l'App Store. Per evitar que et cobrin dues vegades, cancel·la'n una."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Sol·licitud d'assistència"; +"customer_center_support_body" = "Descriu el teu problema o dubte."; +"customer_center_no_mail_app" = "Aquest dispositiu no té cap aplicació de correu configurada. Ens pots contactar a %@."; +"customer_center_detail_nothing_to_manage" = "No hi ha res per gestionar en aquesta compra."; +"customer_center_detail_managed_through" = "Gestiona aquesta subscripció a través de %@."; +"customer_center_detail_managed_where_bought" = "Gestiona aquesta subscripció on la vas comprar."; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 615d4dfaa9..2d23afe7a1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Hotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Spravovat vaše předplatné"; +"customer_center_no_purchases_title" = "Nebylo nalezeno žádné předplatné"; +"customer_center_no_purchases_subtitle" = "Můžeme zkontrolovat předchozí nákupy."; +"customer_center_close" = "Zavřít"; +"customer_center_done" = "Hotovo"; +"customer_center_cancel" = "Zrušit"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Obnovit nákupy"; +"customer_center_path_manage_subscription" = "Zrušit předplatné"; +"customer_center_path_manage_subscription_web" = "Spravovat předplatné"; +"customer_center_web_manage_unavailable" = "Spravujte předplatné pomocí odkazu v e-mailové účtence."; +"customer_center_path_refund" = "Požádat o vrácení peněz"; +"customer_center_path_change_plan" = "Změnit plán"; +"customer_center_path_contact_support" = "Kontaktovat podporu"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Proč rušíte předplatné?"; +"customer_center_survey_too_expensive" = "Příliš drahé"; +"customer_center_survey_dont_use" = "Aplikaci nepoužívám"; +"customer_center_survey_bought_by_mistake" = "Koupeno omylem"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Obnoví se %@ za %@"; +"customer_center_renews_on" = "Obnoví se %@"; +"customer_center_expires_on" = "Vyprší %@"; +"customer_center_expired_on" = "Vypršelo %@"; +"customer_center_free_trial_until" = "Zkušební verze zdarma do %@"; +"customer_center_billing_issue" = "Problém s platbou – aktualizujte platební metodu, abyste si zachovali přístup"; +"customer_center_lifetime" = "Doživotní přístup"; +"customer_center_revoked" = "Vráceno"; +"customer_center_purchased_on" = "Zakoupeno %@"; +"customer_center_active_via_superwall" = "Aktivní"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Vypršelo"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktivní"; +"customer_center_badge_free_trial" = "Zkušební verze zdarma"; +"customer_center_badge_cancelled" = "Zrušeno"; +"customer_center_badge_billing_issue" = "Problém s platbou"; +"customer_center_badge_expired" = "Vypršelo"; +"customer_center_badge_revoked" = "Vráceno"; +"customer_center_badge_lifetime" = "Doživotní"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Jiné"; +"customer_center_family_shared" = "Sdíleno prostřednictvím rodinného sdílení"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Předplatná"; +"customer_center_section_purchases" = "Nákupy"; +"customer_center_section_actions" = "Akce"; +"customer_center_account_details" = "Podrobnosti o účtu"; +"customer_center_user_id" = "ID uživatele"; +"customer_center_copy" = "Kopírovat"; +"customer_center_copied" = "Zkopírováno"; +"customer_center_original_download_date" = "Datum původního stažení"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Obnovování…"; +"customer_center_restore_success_title" = "Nákupy obnoveny"; +"customer_center_restore_success_message" = "Obnovili jsme vaše předchozí nákupy a přiřadili je k vašemu účtu."; +"customer_center_restore_none_title" = "Žádné předchozí nákupy"; +"customer_center_restore_none_message" = "Pro váš účet jsme nenašli žádné nákupy. Pokud si myslíte, že jde o chybu, kontaktujte podporu."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple obdrželo vaši žádost o vrácení peněz."; +"customer_center_refund_error" = "Při žádosti o vrácení peněz se něco pokazilo. Zkuste to prosím znovu."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "K dispozici je aktualizace"; +"customer_center_update_message" = "Stažení nejnovější verze aplikace může pomoci problém vyřešit."; +"customer_center_update_action" = "Aktualizovat"; +"customer_center_update_continue" = "Pokračovat"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Možná máte duplicitní předplatná"; +"customer_center_duplicate_message" = "Je možné, že jste předplatitelem na webu i přes App Store zároveň. Abyste se vyhnuli dvojímu placení, jedno z nich zrušte."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Žádost o podporu"; +"customer_center_support_body" = "Popište prosím svůj problém nebo dotaz."; +"customer_center_no_mail_app" = "V tomto zařízení není nastavena žádná e-mailová aplikace. Můžete nás kontaktovat na %@."; +"customer_center_detail_nothing_to_manage" = "U tohoto nákupu není co spravovat."; +"customer_center_detail_managed_through" = "Toto předplatné spravujte přes %@."; +"customer_center_detail_managed_where_bought" = "Toto předplatné spravujte tam, kde jste ho zakoupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index f640444c4f..0e5f8ea0ec 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Færdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer dit abonnement"; +"customer_center_no_purchases_title" = "Ingen abonnementer fundet"; +"customer_center_no_purchases_subtitle" = "Vi kan tjekke for tidligere køb."; +"customer_center_close" = "Luk"; +"customer_center_done" = "Udført"; +"customer_center_cancel" = "Annuller"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Gendan køb"; +"customer_center_path_manage_subscription" = "Opsig abonnement"; +"customer_center_path_manage_subscription_web" = "Administrer abonnement"; +"customer_center_web_manage_unavailable" = "Administrer dit abonnement via linket i din kvittering på e-mail."; +"customer_center_path_refund" = "Anmod om refundering"; +"customer_center_path_change_plan" = "Skift abonnement"; +"customer_center_path_contact_support" = "Kontakt support"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Hvorfor opsiger du?"; +"customer_center_survey_too_expensive" = "For dyrt"; +"customer_center_survey_dont_use" = "Bruger ikke appen"; +"customer_center_survey_bought_by_mistake" = "Købt ved en fejl"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Fornyes den %@ for %@"; +"customer_center_renews_on" = "Fornyes den %@"; +"customer_center_expires_on" = "Udløber den %@"; +"customer_center_expired_on" = "Udløb den %@"; +"customer_center_free_trial_until" = "Gratis prøveperiode indtil %@"; +"customer_center_billing_issue" = "Betalingsproblem – opdater din betalingsmetode for at beholde adgangen"; +"customer_center_lifetime" = "Livstidsadgang"; +"customer_center_revoked" = "Refunderet"; +"customer_center_purchased_on" = "Købt den %@"; +"customer_center_active_via_superwall" = "Aktiv"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Udløbet"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiv"; +"customer_center_badge_free_trial" = "Gratis prøveperiode"; +"customer_center_badge_cancelled" = "Opsagt"; +"customer_center_badge_billing_issue" = "Betalingsproblem"; +"customer_center_badge_expired" = "Udløbet"; +"customer_center_badge_revoked" = "Refunderet"; +"customer_center_badge_lifetime" = "Livstid"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Andet"; +"customer_center_family_shared" = "Delt via Familiedeling"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnementer"; +"customer_center_section_purchases" = "Køb"; +"customer_center_section_actions" = "Handlinger"; +"customer_center_account_details" = "Kontooplysninger"; +"customer_center_user_id" = "Bruger-id"; +"customer_center_copy" = "Kopiér"; +"customer_center_copied" = "Kopieret"; +"customer_center_original_download_date" = "Oprindelig downloaddato"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Gendanner…"; +"customer_center_restore_success_title" = "Køb gendannet"; +"customer_center_restore_success_message" = "Vi har gendannet dine tidligere køb og anvendt dem på din konto."; +"customer_center_restore_none_title" = "Ingen tidligere køb"; +"customer_center_restore_none_message" = "Vi kunne ikke finde nogen køb til din konto. Hvis du mener, dette er en fejl, bedes du kontakte support."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple har modtaget din anmodning om refundering."; +"customer_center_refund_error" = "Der opstod en fejl under anmodning om refundering. Prøv igen."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Opdatering tilgængelig"; +"customer_center_update_message" = "Det kan hjælpe med at løse problemet at downloade den nyeste version af appen."; +"customer_center_update_action" = "Opdater"; +"customer_center_update_continue" = "Fortsæt"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Du har muligvis dobbelte abonnementer"; +"customer_center_duplicate_message" = "Du er muligvis abonnent både på nettet og via App Store. For at undgå at blive opkrævet to gange bør du opsige det ene."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Supportanmodning"; +"customer_center_support_body" = "Beskriv venligst dit problem eller spørgsmål."; +"customer_center_no_mail_app" = "Der er ikke konfigureret en mailapp på denne enhed. Du kan kontakte os på %@."; +"customer_center_detail_nothing_to_manage" = "Der er intet at administrere for dette køb."; +"customer_center_detail_managed_through" = "Administrer dette abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnement dér, hvor du købte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index d9ea03f4da..549abf352f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fertig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Abo verwalten"; +"customer_center_no_purchases_title" = "Keine Abonnements gefunden"; +"customer_center_no_purchases_subtitle" = "Wir können nach früheren Käufen suchen."; +"customer_center_close" = "Schließen"; +"customer_center_done" = "Fertig"; +"customer_center_cancel" = "Abbrechen"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Käufe wiederherstellen"; +"customer_center_path_manage_subscription" = "Abo kündigen"; +"customer_center_path_manage_subscription_web" = "Abo verwalten"; +"customer_center_web_manage_unavailable" = "Verwalte dein Abo über den Link in deiner E-Mail-Rechnung."; +"customer_center_path_refund" = "Rückerstattung anfordern"; +"customer_center_path_change_plan" = "Tarif ändern"; +"customer_center_path_contact_support" = "Support kontaktieren"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Warum kündigen Sie?"; +"customer_center_survey_too_expensive" = "Zu teuer"; +"customer_center_survey_dont_use" = "Ich nutze die App nicht"; +"customer_center_survey_bought_by_mistake" = "Versehentlich gekauft"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Verlängert sich am %@ für %@"; +"customer_center_renews_on" = "Verlängert sich am %@"; +"customer_center_expires_on" = "Läuft am %@ ab"; +"customer_center_expired_on" = "Am %@ abgelaufen"; +"customer_center_free_trial_until" = "Kostenlose Testversion bis %@"; +"customer_center_billing_issue" = "Zahlungsproblem – aktualisieren Sie Ihre Zahlungsmethode, um den Zugriff zu behalten"; +"customer_center_lifetime" = "Lebenslanger Zugriff"; +"customer_center_revoked" = "Erstattet"; +"customer_center_purchased_on" = "Gekauft am %@"; +"customer_center_active_via_superwall" = "Aktiv"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Abgelaufen"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiv"; +"customer_center_badge_free_trial" = "Kostenlose Testversion"; +"customer_center_badge_cancelled" = "Gekündigt"; +"customer_center_badge_billing_issue" = "Zahlungsproblem"; +"customer_center_badge_expired" = "Abgelaufen"; +"customer_center_badge_revoked" = "Erstattet"; +"customer_center_badge_lifetime" = "Lebenslang"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Andere"; +"customer_center_family_shared" = "Über Familienfreigabe geteilt"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abos"; +"customer_center_section_purchases" = "Käufe"; +"customer_center_section_actions" = "Aktionen"; +"customer_center_account_details" = "Kontodetails"; +"customer_center_user_id" = "Benutzer-ID"; +"customer_center_copy" = "Kopieren"; +"customer_center_copied" = "Kopiert"; +"customer_center_original_download_date" = "Ursprüngliches Downloaddatum"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Wird wiederhergestellt…"; +"customer_center_restore_success_title" = "Käufe wiederhergestellt"; +"customer_center_restore_success_message" = "Wir haben Ihre früheren Käufe wiederhergestellt und Ihrem Konto zugeordnet."; +"customer_center_restore_none_title" = "Keine früheren Käufe"; +"customer_center_restore_none_message" = "Wir konnten keine Käufe für Ihr Konto finden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich bitte an den Support."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple hat Ihre Rückerstattungsanfrage erhalten."; +"customer_center_refund_error" = "Bei der Rückerstattungsanfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Update verfügbar"; +"customer_center_update_message" = "Das Herunterladen der neuesten App-Version könnte helfen, das Problem zu lösen."; +"customer_center_update_action" = "Aktualisieren"; +"customer_center_update_continue" = "Weiter"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Sie haben möglicherweise doppelte Abos"; +"customer_center_duplicate_message" = "Möglicherweise sind Sie sowohl im Web als auch über den App Store abonniert. Um eine doppelte Abbuchung zu vermeiden, kündigen Sie eines davon."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support-Anfrage"; +"customer_center_support_body" = "Bitte beschreiben Sie Ihr Problem oder Ihre Frage."; +"customer_center_no_mail_app" = "Auf diesem Gerät ist keine Mail-App eingerichtet. Sie erreichen uns unter %@."; +"customer_center_detail_nothing_to_manage" = "Für diesen Kauf gibt es nichts zu verwalten."; +"customer_center_detail_managed_through" = "Verwalte dieses Abo über %@."; +"customer_center_detail_managed_where_bought" = "Verwalte dieses Abo dort, wo du es gekauft hast."; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 851ad742ea..667559515e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Τέλος"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Διαχείριση της συνδρομής σας"; +"customer_center_no_purchases_title" = "Δεν βρέθηκαν συνδρομές"; +"customer_center_no_purchases_subtitle" = "Μπορούμε να ελέγξουμε για προηγούμενες αγορές."; +"customer_center_close" = "Κλείσιμο"; +"customer_center_done" = "Τέλος"; +"customer_center_cancel" = "Ακύρωση"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Επαναφορά αγορών"; +"customer_center_path_manage_subscription" = "Ακύρωση συνδρομής"; +"customer_center_path_manage_subscription_web" = "Διαχείριση συνδρομής"; +"customer_center_web_manage_unavailable" = "Διαχειριστείτε τη συνδρομή σας μέσω του συνδέσμου στην απόδειξη email σας."; +"customer_center_path_refund" = "Αίτημα επιστροφής χρημάτων"; +"customer_center_path_change_plan" = "Αλλαγή πλάνου"; +"customer_center_path_contact_support" = "Επικοινωνία με την υποστήριξη"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Γιατί ακυρώνετε;"; +"customer_center_survey_too_expensive" = "Πολύ ακριβό"; +"customer_center_survey_dont_use" = "Δεν χρησιμοποιώ την εφαρμογή"; +"customer_center_survey_bought_by_mistake" = "Αγοράστηκε κατά λάθος"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Ανανεώνεται στις %@ για %@"; +"customer_center_renews_on" = "Ανανεώνεται στις %@"; +"customer_center_expires_on" = "Λήγει στις %@"; +"customer_center_expired_on" = "Έληξε στις %@"; +"customer_center_free_trial_until" = "Δωρεάν δοκιμή έως %@"; +"customer_center_billing_issue" = "Πρόβλημα χρέωσης – ενημερώστε τον τρόπο πληρωμής σας για να διατηρήσετε την πρόσβαση"; +"customer_center_lifetime" = "Πρόσβαση ισόβια"; +"customer_center_revoked" = "Επιστράφηκαν τα χρήματα"; +"customer_center_purchased_on" = "Αγοράστηκε στις %@"; +"customer_center_active_via_superwall" = "Ενεργή"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Έληξε"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Ενεργή"; +"customer_center_badge_free_trial" = "Δωρεάν δοκιμή"; +"customer_center_badge_cancelled" = "Ακυρώθηκε"; +"customer_center_badge_billing_issue" = "Πρόβλημα χρέωσης"; +"customer_center_badge_expired" = "Έληξε"; +"customer_center_badge_revoked" = "Επιστράφηκαν τα χρήματα"; +"customer_center_badge_lifetime" = "Ισόβια"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Ιστός"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Άλλο"; +"customer_center_family_shared" = "Κοινή χρήση μέσω Οικογενειακού Κοινόχρηστου"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Συνδρομές"; +"customer_center_section_purchases" = "Αγορές"; +"customer_center_section_actions" = "Ενέργειες"; +"customer_center_account_details" = "Στοιχεία λογαριασμού"; +"customer_center_user_id" = "Αναγνωριστικό χρήστη"; +"customer_center_copy" = "Αντιγραφή"; +"customer_center_copied" = "Αντιγράφηκε"; +"customer_center_original_download_date" = "Αρχική ημερομηνία λήψης"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Γίνεται επαναφορά…"; +"customer_center_restore_success_title" = "Οι αγορές επαναφέρθηκαν"; +"customer_center_restore_success_message" = "Επαναφέραμε τις προηγούμενες αγορές σας και τις εφαρμόσαμε στον λογαριασμό σας."; +"customer_center_restore_none_title" = "Καμία προηγούμενη αγορά"; +"customer_center_restore_none_message" = "Δεν βρέθηκαν αγορές για τον λογαριασμό σας. Αν πιστεύετε ότι πρόκειται για σφάλμα, επικοινωνήστε με την υποστήριξη."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Η Apple έλαβε το αίτημά σας για επιστροφή χρημάτων."; +"customer_center_refund_error" = "Κάτι πήγε στραβά κατά το αίτημα επιστροφής χρημάτων. Δοκιμάστε ξανά."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Διαθέσιμη ενημέρωση"; +"customer_center_update_message" = "Η λήψη της πιο πρόσφατης έκδοσης της εφαρμογής ενδέχεται να βοηθήσει στην επίλυση του προβλήματος."; +"customer_center_update_action" = "Ενημέρωση"; +"customer_center_update_continue" = "Συνέχεια"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Ενδέχεται να έχετε διπλές συνδρομές"; +"customer_center_duplicate_message" = "Ενδέχεται να έχετε συνδρομή τόσο μέσω ιστού όσο και μέσω του App Store. Για να αποφύγετε διπλή χρέωση, ακυρώστε τη μία."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Αίτημα υποστήριξης"; +"customer_center_support_body" = "Περιγράψτε το πρόβλημα ή την ερώτησή σας."; +"customer_center_no_mail_app" = "Δεν έχει ρυθμιστεί εφαρμογή αλληλογραφίας σε αυτή τη συσκευή. Μπορείτε να επικοινωνήσετε μαζί μας στο %@."; +"customer_center_detail_nothing_to_manage" = "Δεν υπάρχει κάτι προς διαχείριση για αυτήν την αγορά."; +"customer_center_detail_managed_through" = "Διαχειριστείτε αυτή τη συνδρομή μέσω %@."; +"customer_center_detail_managed_where_bought" = "Διαχειριστείτε αυτή τη συνδρομή εκεί όπου την αγοράσατε."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 9a9418c643..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_purchases_title" = "No subscriptions found"; +"customer_center_no_purchases_subtitle" = "We can check for previous purchases."; +"customer_center_close" = "Close"; +"customer_center_done" = "Done"; +"customer_center_cancel" = "Cancel"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restore purchases"; +"customer_center_path_manage_subscription" = "Cancel subscription"; +"customer_center_path_manage_subscription_web" = "Manage subscription"; +"customer_center_web_manage_unavailable" = "Manage your subscription using the link in your emailed receipt."; +"customer_center_path_refund" = "Request a refund"; +"customer_center_path_change_plan" = "Change plan"; +"customer_center_path_contact_support" = "Contact support"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Why are you cancelling?"; +"customer_center_survey_too_expensive" = "Too expensive"; +"customer_center_survey_dont_use" = "Don't use the app"; +"customer_center_survey_bought_by_mistake" = "Bought by mistake"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renews on %@ for %@"; +"customer_center_renews_on" = "Renews on %@"; +"customer_center_expires_on" = "Expires on %@"; +"customer_center_expired_on" = "Expired on %@"; +"customer_center_free_trial_until" = "Free trial until %@"; +"customer_center_billing_issue" = "Billing issue – update your payment method to keep access"; +"customer_center_lifetime" = "Lifetime access"; +"customer_center_revoked" = "Refunded"; +"customer_center_purchased_on" = "Purchased on %@"; +"customer_center_active_via_superwall" = "Active"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expired"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Active"; +"customer_center_badge_free_trial" = "Free trial"; +"customer_center_badge_cancelled" = "Cancelled"; +"customer_center_badge_billing_issue" = "Billing issue"; +"customer_center_badge_expired" = "Expired"; +"customer_center_badge_revoked" = "Refunded"; +"customer_center_badge_lifetime" = "Lifetime"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Other"; +"customer_center_family_shared" = "Shared through Family Sharing"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscriptions"; +"customer_center_section_purchases" = "Purchases"; +"customer_center_section_actions" = "Actions"; +"customer_center_account_details" = "Account details"; +"customer_center_user_id" = "User ID"; +"customer_center_copy" = "Copy"; +"customer_center_copied" = "Copied"; +"customer_center_original_download_date" = "Original download date"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restoring…"; +"customer_center_restore_success_title" = "Purchases restored"; +"customer_center_restore_success_message" = "We restored your past purchases and applied them to your account."; +"customer_center_restore_none_title" = "No past purchases"; +"customer_center_restore_none_message" = "We couldn't find any purchases for your account. If you think this is an error, please contact support."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple has received your refund request."; +"customer_center_refund_error" = "Something went wrong requesting a refund. Please try again."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Update available"; +"customer_center_update_message" = "Downloading the latest version of the app may help solve the problem."; +"customer_center_update_action" = "Update"; +"customer_center_update_continue" = "Continue"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "You may have duplicate subscriptions"; +"customer_center_duplicate_message" = "You might be subscribed both on the web and through the App Store. To avoid being charged twice, cancel one of them."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support request"; +"customer_center_support_body" = "Please describe your issue or question."; +"customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 9a9418c643..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_purchases_title" = "No subscriptions found"; +"customer_center_no_purchases_subtitle" = "We can check for previous purchases."; +"customer_center_close" = "Close"; +"customer_center_done" = "Done"; +"customer_center_cancel" = "Cancel"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restore purchases"; +"customer_center_path_manage_subscription" = "Cancel subscription"; +"customer_center_path_manage_subscription_web" = "Manage subscription"; +"customer_center_web_manage_unavailable" = "Manage your subscription using the link in your emailed receipt."; +"customer_center_path_refund" = "Request a refund"; +"customer_center_path_change_plan" = "Change plan"; +"customer_center_path_contact_support" = "Contact support"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Why are you cancelling?"; +"customer_center_survey_too_expensive" = "Too expensive"; +"customer_center_survey_dont_use" = "Don't use the app"; +"customer_center_survey_bought_by_mistake" = "Bought by mistake"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renews on %@ for %@"; +"customer_center_renews_on" = "Renews on %@"; +"customer_center_expires_on" = "Expires on %@"; +"customer_center_expired_on" = "Expired on %@"; +"customer_center_free_trial_until" = "Free trial until %@"; +"customer_center_billing_issue" = "Billing issue – update your payment method to keep access"; +"customer_center_lifetime" = "Lifetime access"; +"customer_center_revoked" = "Refunded"; +"customer_center_purchased_on" = "Purchased on %@"; +"customer_center_active_via_superwall" = "Active"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expired"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Active"; +"customer_center_badge_free_trial" = "Free trial"; +"customer_center_badge_cancelled" = "Cancelled"; +"customer_center_badge_billing_issue" = "Billing issue"; +"customer_center_badge_expired" = "Expired"; +"customer_center_badge_revoked" = "Refunded"; +"customer_center_badge_lifetime" = "Lifetime"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Other"; +"customer_center_family_shared" = "Shared through Family Sharing"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscriptions"; +"customer_center_section_purchases" = "Purchases"; +"customer_center_section_actions" = "Actions"; +"customer_center_account_details" = "Account details"; +"customer_center_user_id" = "User ID"; +"customer_center_copy" = "Copy"; +"customer_center_copied" = "Copied"; +"customer_center_original_download_date" = "Original download date"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restoring…"; +"customer_center_restore_success_title" = "Purchases restored"; +"customer_center_restore_success_message" = "We restored your past purchases and applied them to your account."; +"customer_center_restore_none_title" = "No past purchases"; +"customer_center_restore_none_message" = "We couldn't find any purchases for your account. If you think this is an error, please contact support."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple has received your refund request."; +"customer_center_refund_error" = "Something went wrong requesting a refund. Please try again."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Update available"; +"customer_center_update_message" = "Downloading the latest version of the app may help solve the problem."; +"customer_center_update_action" = "Update"; +"customer_center_update_continue" = "Continue"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "You may have duplicate subscriptions"; +"customer_center_duplicate_message" = "You might be subscribed both on the web and through the App Store. To avoid being charged twice, cancel one of them."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support request"; +"customer_center_support_body" = "Please describe your issue or question."; +"customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 9a9418c643..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_purchases_title" = "No subscriptions found"; +"customer_center_no_purchases_subtitle" = "We can check for previous purchases."; +"customer_center_close" = "Close"; +"customer_center_done" = "Done"; +"customer_center_cancel" = "Cancel"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restore purchases"; +"customer_center_path_manage_subscription" = "Cancel subscription"; +"customer_center_path_manage_subscription_web" = "Manage subscription"; +"customer_center_web_manage_unavailable" = "Manage your subscription using the link in your emailed receipt."; +"customer_center_path_refund" = "Request a refund"; +"customer_center_path_change_plan" = "Change plan"; +"customer_center_path_contact_support" = "Contact support"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Why are you cancelling?"; +"customer_center_survey_too_expensive" = "Too expensive"; +"customer_center_survey_dont_use" = "Don't use the app"; +"customer_center_survey_bought_by_mistake" = "Bought by mistake"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renews on %@ for %@"; +"customer_center_renews_on" = "Renews on %@"; +"customer_center_expires_on" = "Expires on %@"; +"customer_center_expired_on" = "Expired on %@"; +"customer_center_free_trial_until" = "Free trial until %@"; +"customer_center_billing_issue" = "Billing issue – update your payment method to keep access"; +"customer_center_lifetime" = "Lifetime access"; +"customer_center_revoked" = "Refunded"; +"customer_center_purchased_on" = "Purchased on %@"; +"customer_center_active_via_superwall" = "Active"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expired"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Active"; +"customer_center_badge_free_trial" = "Free trial"; +"customer_center_badge_cancelled" = "Cancelled"; +"customer_center_badge_billing_issue" = "Billing issue"; +"customer_center_badge_expired" = "Expired"; +"customer_center_badge_revoked" = "Refunded"; +"customer_center_badge_lifetime" = "Lifetime"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Other"; +"customer_center_family_shared" = "Shared through Family Sharing"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscriptions"; +"customer_center_section_purchases" = "Purchases"; +"customer_center_section_actions" = "Actions"; +"customer_center_account_details" = "Account details"; +"customer_center_user_id" = "User ID"; +"customer_center_copy" = "Copy"; +"customer_center_copied" = "Copied"; +"customer_center_original_download_date" = "Original download date"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restoring…"; +"customer_center_restore_success_title" = "Purchases restored"; +"customer_center_restore_success_message" = "We restored your past purchases and applied them to your account."; +"customer_center_restore_none_title" = "No past purchases"; +"customer_center_restore_none_message" = "We couldn't find any purchases for your account. If you think this is an error, please contact support."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple has received your refund request."; +"customer_center_refund_error" = "Something went wrong requesting a refund. Please try again."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Update available"; +"customer_center_update_message" = "Downloading the latest version of the app may help solve the problem."; +"customer_center_update_action" = "Update"; +"customer_center_update_continue" = "Continue"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "You may have duplicate subscriptions"; +"customer_center_duplicate_message" = "You might be subscribed both on the web and through the App Store. To avoid being charged twice, cancel one of them."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support request"; +"customer_center_support_body" = "Please describe your issue or question."; +"customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index 28671384ee..c49891242b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Listo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestione su suscripción"; +"customer_center_no_purchases_title" = "No se encontraron suscripciones"; +"customer_center_no_purchases_subtitle" = "Podemos comprobar si hay compras anteriores."; +"customer_center_close" = "Cerrar"; +"customer_center_done" = "Listo"; +"customer_center_cancel" = "Cancelar"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurar compras"; +"customer_center_path_manage_subscription" = "Cancelar suscripción"; +"customer_center_path_manage_subscription_web" = "Gestionar suscripción"; +"customer_center_web_manage_unavailable" = "Gestiona tu suscripción con el enlace de tu recibo por correo electrónico."; +"customer_center_path_refund" = "Solicitar un reembolso"; +"customer_center_path_change_plan" = "Cambiar de plan"; +"customer_center_path_contact_support" = "Contactar con soporte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "¿Por qué cancela?"; +"customer_center_survey_too_expensive" = "Demasiado caro"; +"customer_center_survey_dont_use" = "No uso la aplicación"; +"customer_center_survey_bought_by_mistake" = "Lo compré por error"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Se renueva el %@ por %@"; +"customer_center_renews_on" = "Se renueva el %@"; +"customer_center_expires_on" = "Caduca el %@"; +"customer_center_expired_on" = "Caducó el %@"; +"customer_center_free_trial_until" = "Prueba gratuita hasta el %@"; +"customer_center_billing_issue" = "Problema de facturación: actualice su método de pago para conservar el acceso"; +"customer_center_lifetime" = "Acceso de por vida"; +"customer_center_revoked" = "Reembolsado"; +"customer_center_purchased_on" = "Comprado el %@"; +"customer_center_active_via_superwall" = "Activa"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Caducada"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Activa"; +"customer_center_badge_free_trial" = "Prueba gratuita"; +"customer_center_badge_cancelled" = "Cancelada"; +"customer_center_badge_billing_issue" = "Problema de facturación"; +"customer_center_badge_expired" = "Caducada"; +"customer_center_badge_revoked" = "Reembolsado"; +"customer_center_badge_lifetime" = "De por vida"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Otro"; +"customer_center_family_shared" = "Compartido mediante En familia"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Suscripciones"; +"customer_center_section_purchases" = "Compras"; +"customer_center_section_actions" = "Acciones"; +"customer_center_account_details" = "Detalles de la cuenta"; +"customer_center_user_id" = "ID de usuario"; +"customer_center_copy" = "Copiar"; +"customer_center_copied" = "Copiado"; +"customer_center_original_download_date" = "Fecha de descarga original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restaurando…"; +"customer_center_restore_success_title" = "Compras restauradas"; +"customer_center_restore_success_message" = "Hemos restaurado sus compras anteriores y las hemos aplicado a su cuenta."; +"customer_center_restore_none_title" = "Sin compras anteriores"; +"customer_center_restore_none_message" = "No hemos encontrado ninguna compra para su cuenta. Si cree que se trata de un error, póngase en contacto con soporte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple ha recibido su solicitud de reembolso."; +"customer_center_refund_error" = "Se produjo un error al solicitar el reembolso. Inténtelo de nuevo."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Actualización disponible"; +"customer_center_update_message" = "Descargar la última versión de la aplicación puede ayudar a solucionar el problema."; +"customer_center_update_action" = "Actualizar"; +"customer_center_update_continue" = "Continuar"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Puede que tenga suscripciones duplicadas"; +"customer_center_duplicate_message" = "Es posible que esté suscrito tanto en la web como a través de la App Store. Para evitar que le cobren dos veces, cancele una de ellas."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Solicitud de soporte"; +"customer_center_support_body" = "Describa su problema o pregunta."; +"customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puede contactarnos en %@."; +"customer_center_detail_nothing_to_manage" = "No hay nada que gestionar en esta compra."; +"customer_center_detail_managed_through" = "Gestiona esta suscripción a través de %@."; +"customer_center_detail_managed_where_bought" = "Gestiona esta suscripción donde la compraste."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 82c92669dd..329b9a4ed4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Listo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestiona tu suscripción"; +"customer_center_no_purchases_title" = "No se encontraron suscripciones"; +"customer_center_no_purchases_subtitle" = "Podemos comprobar si hay compras anteriores."; +"customer_center_close" = "Cerrar"; +"customer_center_done" = "Listo"; +"customer_center_cancel" = "Cancelar"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurar compras"; +"customer_center_path_manage_subscription" = "Cancelar suscripción"; +"customer_center_path_manage_subscription_web" = "Administrar suscripción"; +"customer_center_web_manage_unavailable" = "Administra tu suscripción con el enlace de tu recibo por correo electrónico."; +"customer_center_path_refund" = "Solicitar un reembolso"; +"customer_center_path_change_plan" = "Cambiar de plan"; +"customer_center_path_contact_support" = "Contactar con soporte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "¿Por qué cancelas?"; +"customer_center_survey_too_expensive" = "Demasiado caro"; +"customer_center_survey_dont_use" = "No uso la aplicación"; +"customer_center_survey_bought_by_mistake" = "Lo compré por error"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Se renueva el %@ por %@"; +"customer_center_renews_on" = "Se renueva el %@"; +"customer_center_expires_on" = "Caduca el %@"; +"customer_center_expired_on" = "Caducó el %@"; +"customer_center_free_trial_until" = "Prueba gratuita hasta el %@"; +"customer_center_billing_issue" = "Problema de facturación: actualiza tu método de pago para conservar el acceso"; +"customer_center_lifetime" = "Acceso de por vida"; +"customer_center_revoked" = "Reembolsado"; +"customer_center_purchased_on" = "Comprado el %@"; +"customer_center_active_via_superwall" = "Activa"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Caducada"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Activa"; +"customer_center_badge_free_trial" = "Prueba gratuita"; +"customer_center_badge_cancelled" = "Cancelada"; +"customer_center_badge_billing_issue" = "Problema de facturación"; +"customer_center_badge_expired" = "Caducada"; +"customer_center_badge_revoked" = "Reembolsado"; +"customer_center_badge_lifetime" = "De por vida"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Otro"; +"customer_center_family_shared" = "Compartido mediante En familia"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Suscripciones"; +"customer_center_section_purchases" = "Compras"; +"customer_center_section_actions" = "Acciones"; +"customer_center_account_details" = "Detalles de la cuenta"; +"customer_center_user_id" = "ID de usuario"; +"customer_center_copy" = "Copiar"; +"customer_center_copied" = "Copiado"; +"customer_center_original_download_date" = "Fecha de descarga original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restaurando…"; +"customer_center_restore_success_title" = "Compras restauradas"; +"customer_center_restore_success_message" = "Hemos restaurado tus compras anteriores y las hemos aplicado a tu cuenta."; +"customer_center_restore_none_title" = "Sin compras anteriores"; +"customer_center_restore_none_message" = "No hemos encontrado ninguna compra para tu cuenta. Si crees que se trata de un error, ponte en contacto con soporte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple ha recibido tu solicitud de reembolso."; +"customer_center_refund_error" = "Se produjo un error al solicitar el reembolso. Inténtalo de nuevo."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Actualización disponible"; +"customer_center_update_message" = "Descargar la última versión de la aplicación puede ayudar a solucionar el problema."; +"customer_center_update_action" = "Actualizar"; +"customer_center_update_continue" = "Continuar"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Puede que tengas suscripciones duplicadas"; +"customer_center_duplicate_message" = "Es posible que estés suscrito tanto en la web como a través de la App Store. Para evitar que te cobren dos veces, cancela una de ellas."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Solicitud de soporte"; +"customer_center_support_body" = "Describe tu problema o pregunta."; +"customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puedes contactarnos en %@."; +"customer_center_detail_nothing_to_manage" = "No hay nada que administrar en esta compra."; +"customer_center_detail_managed_through" = "Administra esta suscripción a través de %@."; +"customer_center_detail_managed_where_bought" = "Administra esta suscripción donde la compraste."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 2248bda7a7..9b24d3d4e4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Valmis"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Hallinnoi tilaustasi"; +"customer_center_no_purchases_title" = "Tilauksia ei löytynyt"; +"customer_center_no_purchases_subtitle" = "Voimme tarkistaa aiemmat ostokset."; +"customer_center_close" = "Sulje"; +"customer_center_done" = "Valmis"; +"customer_center_cancel" = "Peruuta"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Palauta ostokset"; +"customer_center_path_manage_subscription" = "Peruuta tilaus"; +"customer_center_path_manage_subscription_web" = "Hallinnoi tilausta"; +"customer_center_web_manage_unavailable" = "Hallinnoi tilaustasi sähköpostikuitissa olevan linkin kautta."; +"customer_center_path_refund" = "Pyydä hyvitystä"; +"customer_center_path_change_plan" = "Vaihda tilaustasoa"; +"customer_center_path_contact_support" = "Ota yhteyttä tukeen"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Miksi peruutat tilauksen?"; +"customer_center_survey_too_expensive" = "Liian kallis"; +"customer_center_survey_dont_use" = "En käytä sovellusta"; +"customer_center_survey_bought_by_mistake" = "Ostettu vahingossa"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Uusiutuu %@ hintaan %@"; +"customer_center_renews_on" = "Uusiutuu %@"; +"customer_center_expires_on" = "Vanhenee %@"; +"customer_center_expired_on" = "Vanheni %@"; +"customer_center_free_trial_until" = "Ilmainen kokeilu %@ asti"; +"customer_center_billing_issue" = "Laskutusongelma – päivitä maksutapasi säilyttääksesi käyttöoikeuden"; +"customer_center_lifetime" = "Elinikäinen käyttöoikeus"; +"customer_center_revoked" = "Hyvitetty"; +"customer_center_purchased_on" = "Ostettu %@"; +"customer_center_active_via_superwall" = "Aktiivinen"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Vanhentunut"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiivinen"; +"customer_center_badge_free_trial" = "Ilmainen kokeilu"; +"customer_center_badge_cancelled" = "Peruutettu"; +"customer_center_badge_billing_issue" = "Laskutusongelma"; +"customer_center_badge_expired" = "Vanhentunut"; +"customer_center_badge_revoked" = "Hyvitetty"; +"customer_center_badge_lifetime" = "Elinikäinen"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Muu"; +"customer_center_family_shared" = "Jaettu Perhejaon kautta"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Tilaukset"; +"customer_center_section_purchases" = "Ostokset"; +"customer_center_section_actions" = "Toiminnot"; +"customer_center_account_details" = "Tilin tiedot"; +"customer_center_user_id" = "Käyttäjätunnus"; +"customer_center_copy" = "Kopioi"; +"customer_center_copied" = "Kopioitu"; +"customer_center_original_download_date" = "Alkuperäinen latauspäivä"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Palautetaan…"; +"customer_center_restore_success_title" = "Ostokset palautettu"; +"customer_center_restore_success_message" = "Palautimme aiemmat ostoksesi ja lisäsimme ne tilillesi."; +"customer_center_restore_none_title" = "Ei aiempia ostoksia"; +"customer_center_restore_none_message" = "Tilillesi ei löytynyt ostoksia. Jos uskot tämän olevan virhe, ota yhteyttä tukeen."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple on vastaanottanut hyvityspyyntösi."; +"customer_center_refund_error" = "Hyvityspyynnössä tapahtui virhe. Yritä uudelleen."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Päivitys saatavilla"; +"customer_center_update_message" = "Sovelluksen uusimman version lataaminen saattaa auttaa ratkaisemaan ongelman."; +"customer_center_update_action" = "Päivitä"; +"customer_center_update_continue" = "Jatka"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Sinulla saattaa olla päällekkäisiä tilauksia"; +"customer_center_duplicate_message" = "Saatat olla tilaaja sekä verkossa että App Storen kautta. Vältä kaksinkertainen veloitus perumalla toinen niistä."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Tukipyyntö"; +"customer_center_support_body" = "Kuvaile ongelmaasi tai kysymystäsi."; +"customer_center_no_mail_app" = "Tähän laitteeseen ei ole määritetty sähköpostisovellusta. Voit tavoittaa meidät osoitteessa %@."; +"customer_center_detail_nothing_to_manage" = "Tässä ostoksessa ei ole mitään hallittavaa."; +"customer_center_detail_managed_through" = "Hallitse tätä tilausta palvelussa %@."; +"customer_center_detail_managed_where_bought" = "Hallitse tätä tilausta siellä, mistä ostit sen."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 430b917aa9..4b4960bd69 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Terminé"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gérer votre abonnement"; +"customer_center_no_purchases_title" = "Aucun abonnement trouvé"; +"customer_center_no_purchases_subtitle" = "Nous pouvons vérifier vos achats précédents."; +"customer_center_close" = "Fermer"; +"customer_center_done" = "Terminé"; +"customer_center_cancel" = "Annuler"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurer les achats"; +"customer_center_path_manage_subscription" = "Résilier l'abonnement"; +"customer_center_path_manage_subscription_web" = "Gérer l'abonnement"; +"customer_center_web_manage_unavailable" = "Gérez votre abonnement via le lien figurant dans votre reçu par e-mail."; +"customer_center_path_refund" = "Demander un remboursement"; +"customer_center_path_change_plan" = "Changer de formule"; +"customer_center_path_contact_support" = "Contacter l'assistance"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Pourquoi annulez-vous ?"; +"customer_center_survey_too_expensive" = "Trop cher"; +"customer_center_survey_dont_use" = "Je n'utilise pas l'application"; +"customer_center_survey_bought_by_mistake" = "Achat effectué par erreur"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Se renouvelle le %@ pour %@"; +"customer_center_renews_on" = "Se renouvelle le %@"; +"customer_center_expires_on" = "Expire le %@"; +"customer_center_expired_on" = "A expiré le %@"; +"customer_center_free_trial_until" = "Essai gratuit jusqu'au %@"; +"customer_center_billing_issue" = "Problème de facturation – mettez à jour votre moyen de paiement pour conserver l'accès"; +"customer_center_lifetime" = "Accès à vie"; +"customer_center_revoked" = "Remboursé"; +"customer_center_purchased_on" = "Acheté le %@"; +"customer_center_active_via_superwall" = "Actif"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expiré"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Actif"; +"customer_center_badge_free_trial" = "Essai gratuit"; +"customer_center_badge_cancelled" = "Annulé"; +"customer_center_badge_billing_issue" = "Problème de facturation"; +"customer_center_badge_expired" = "Expiré"; +"customer_center_badge_revoked" = "Remboursé"; +"customer_center_badge_lifetime" = "À vie"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Autre"; +"customer_center_family_shared" = "Partagé via le Partage familial"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnements"; +"customer_center_section_purchases" = "Achats"; +"customer_center_section_actions" = "Actions"; +"customer_center_account_details" = "Détails du compte"; +"customer_center_user_id" = "ID utilisateur"; +"customer_center_copy" = "Copier"; +"customer_center_copied" = "Copié"; +"customer_center_original_download_date" = "Date de téléchargement d'origine"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restauration en cours…"; +"customer_center_restore_success_title" = "Achats restaurés"; +"customer_center_restore_success_message" = "Nous avons restauré vos achats précédents et les avons appliqués à votre compte."; +"customer_center_restore_none_title" = "Aucun achat précédent"; +"customer_center_restore_none_message" = "Nous n'avons trouvé aucun achat pour votre compte. Si vous pensez qu'il s'agit d'une erreur, contactez l'assistance."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple a bien reçu votre demande de remboursement."; +"customer_center_refund_error" = "Une erreur s'est produite lors de la demande de remboursement. Veuillez réessayer."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Mise à jour disponible"; +"customer_center_update_message" = "Télécharger la dernière version de l'application peut aider à résoudre le problème."; +"customer_center_update_action" = "Mettre à jour"; +"customer_center_update_continue" = "Continuer"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Vous avez peut-être des abonnements en double"; +"customer_center_duplicate_message" = "Il se peut que vous soyez abonné à la fois sur le web et via l'App Store. Pour éviter d'être facturé deux fois, annulez l'un des deux."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Demande d'assistance"; +"customer_center_support_body" = "Merci de décrire votre problème ou votre question."; +"customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; +"customer_center_detail_nothing_to_manage" = "Il n'y a rien à gérer pour cet achat."; +"customer_center_detail_managed_through" = "Gérez cet abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Gérez cet abonnement là où vous l'avez acheté."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 6f8e72d43c..a7813db734 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Terminé"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gérer votre abonnement"; +"customer_center_no_purchases_title" = "Aucun abonnement trouvé"; +"customer_center_no_purchases_subtitle" = "Nous pouvons vérifier vos achats précédents."; +"customer_center_close" = "Fermer"; +"customer_center_done" = "Terminé"; +"customer_center_cancel" = "Annuler"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurer les achats"; +"customer_center_path_manage_subscription" = "Résilier l'abonnement"; +"customer_center_path_manage_subscription_web" = "Gérer l'abonnement"; +"customer_center_web_manage_unavailable" = "Gérez votre abonnement via le lien figurant dans votre reçu par courriel."; +"customer_center_path_refund" = "Demander un remboursement"; +"customer_center_path_change_plan" = "Changer de formule"; +"customer_center_path_contact_support" = "Contacter l'assistance"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Pourquoi annulez-vous ?"; +"customer_center_survey_too_expensive" = "Trop cher"; +"customer_center_survey_dont_use" = "Je n'utilise pas l'application"; +"customer_center_survey_bought_by_mistake" = "Achat effectué par erreur"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Se renouvelle le %@ pour %@"; +"customer_center_renews_on" = "Se renouvelle le %@"; +"customer_center_expires_on" = "Expire le %@"; +"customer_center_expired_on" = "A expiré le %@"; +"customer_center_free_trial_until" = "Essai gratuit jusqu'au %@"; +"customer_center_billing_issue" = "Problème de facturation – mettez à jour votre moyen de paiement pour conserver l'accès"; +"customer_center_lifetime" = "Accès à vie"; +"customer_center_revoked" = "Remboursé"; +"customer_center_purchased_on" = "Acheté le %@"; +"customer_center_active_via_superwall" = "Actif"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expiré"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Actif"; +"customer_center_badge_free_trial" = "Essai gratuit"; +"customer_center_badge_cancelled" = "Annulé"; +"customer_center_badge_billing_issue" = "Problème de facturation"; +"customer_center_badge_expired" = "Expiré"; +"customer_center_badge_revoked" = "Remboursé"; +"customer_center_badge_lifetime" = "À vie"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Autre"; +"customer_center_family_shared" = "Partagé via le Partage familial"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnements"; +"customer_center_section_purchases" = "Achats"; +"customer_center_section_actions" = "Actions"; +"customer_center_account_details" = "Détails du compte"; +"customer_center_user_id" = "ID utilisateur"; +"customer_center_copy" = "Copier"; +"customer_center_copied" = "Copié"; +"customer_center_original_download_date" = "Date de téléchargement d'origine"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Restauration en cours…"; +"customer_center_restore_success_title" = "Achats restaurés"; +"customer_center_restore_success_message" = "Nous avons restauré vos achats précédents et les avons appliqués à votre compte."; +"customer_center_restore_none_title" = "Aucun achat précédent"; +"customer_center_restore_none_message" = "Nous n'avons trouvé aucun achat pour votre compte. Si vous pensez qu'il s'agit d'une erreur, contactez l'assistance."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple a bien reçu votre demande de remboursement."; +"customer_center_refund_error" = "Une erreur s'est produite lors de la demande de remboursement. Veuillez réessayer."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Mise à jour disponible"; +"customer_center_update_message" = "Télécharger la dernière version de l'application peut aider à résoudre le problème."; +"customer_center_update_action" = "Mettre à jour"; +"customer_center_update_continue" = "Continuer"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Vous avez peut-être des abonnements en double"; +"customer_center_duplicate_message" = "Il se peut que vous soyez abonné à la fois sur le web et via l'App Store. Pour éviter d'être facturé deux fois, annulez l'un des deux."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Demande d'assistance"; +"customer_center_support_body" = "Merci de décrire votre problème ou votre question."; +"customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; +"customer_center_detail_nothing_to_manage" = "Il n'y a rien à gérer pour cet achat."; +"customer_center_detail_managed_through" = "Gérez cet abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Gérez cet abonnement là où vous l'avez acheté."; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 360c7d5fdc..6cb2cd02d6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "סיום"; + +/* Customer Center – screens */ +"customer_center_management_title" = "ניהול המנוי שלך"; +"customer_center_no_purchases_title" = "לא נמצאו מנויים"; +"customer_center_no_purchases_subtitle" = "נוכל לבדוק אם יש רכישות קודמות."; +"customer_center_close" = "סגירה"; +"customer_center_done" = "סיום"; +"customer_center_cancel" = "ביטול"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "שחזור רכישות"; +"customer_center_path_manage_subscription" = "ביטול המנוי"; +"customer_center_path_manage_subscription_web" = "ניהול המנוי"; +"customer_center_web_manage_unavailable" = "נהל את המנוי שלך באמצעות הקישור בקבלה שנשלחה במייל."; +"customer_center_path_refund" = "בקשת החזר כספי"; +"customer_center_path_change_plan" = "שינוי תוכנית"; +"customer_center_path_contact_support" = "יצירת קשר עם התמיכה"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "מדוע אתה מבטל?"; +"customer_center_survey_too_expensive" = "יקר מדי"; +"customer_center_survey_dont_use" = "אני לא משתמש באפליקציה"; +"customer_center_survey_bought_by_mistake" = "נרכש בטעות"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "מתחדש בתאריך %@ תמורת %@"; +"customer_center_renews_on" = "מתחדש בתאריך %@"; +"customer_center_expires_on" = "פג תוקף בתאריך %@"; +"customer_center_expired_on" = "פג תוקף בתאריך %@"; +"customer_center_free_trial_until" = "ניסיון חינם עד %@"; +"customer_center_billing_issue" = "בעיית חיוב – עדכן את אמצעי התשלום שלך כדי לשמור על הגישה"; +"customer_center_lifetime" = "גישה לכל החיים"; +"customer_center_revoked" = "הוחזר הכסף"; +"customer_center_purchased_on" = "נרכש בתאריך %@"; +"customer_center_active_via_superwall" = "פעיל"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "פג תוקף"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "פעיל"; +"customer_center_badge_free_trial" = "ניסיון חינם"; +"customer_center_badge_cancelled" = "בוטל"; +"customer_center_badge_billing_issue" = "בעיית חיוב"; +"customer_center_badge_expired" = "פג תוקף"; +"customer_center_badge_revoked" = "הוחזר הכסף"; +"customer_center_badge_lifetime" = "לכל החיים"; + +/* Customer Center – stores */ +"customer_center_store_web" = "אינטרנט"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "אחר"; +"customer_center_family_shared" = "משותף דרך שיתוף משפחתי"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "מנויים"; +"customer_center_section_purchases" = "רכישות"; +"customer_center_section_actions" = "פעולות"; +"customer_center_account_details" = "פרטי חשבון"; +"customer_center_user_id" = "מזהה משתמש"; +"customer_center_copy" = "העתקה"; +"customer_center_copied" = "הועתק"; +"customer_center_original_download_date" = "תאריך ההורדה המקורי"; + +/* Customer Center – restore */ +"customer_center_restoring" = "משחזר…"; +"customer_center_restore_success_title" = "הרכישות שוחזרו"; +"customer_center_restore_success_message" = "שחזרנו את הרכישות הקודמות שלך והחלנו אותן על החשבון שלך."; +"customer_center_restore_none_title" = "אין רכישות קודמות"; +"customer_center_restore_none_message" = "לא הצלחנו למצוא רכישות עבור החשבון שלך. אם לדעתך מדובר בטעות, צור קשר עם התמיכה."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple קיבלה את בקשת ההחזר הכספי שלך."; +"customer_center_refund_error" = "משהו השתבש בעת בקשת ההחזר הכספי. נסה שוב."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "עדכון זמין"; +"customer_center_update_message" = "הורדת הגרסה העדכנית ביותר של האפליקציה עשויה לסייע בפתרון הבעיה."; +"customer_center_update_action" = "עדכון"; +"customer_center_update_continue" = "המשך"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "ייתכן שיש לך מנויים כפולים"; +"customer_center_duplicate_message" = "ייתכן שאתה מנוי גם באינטרנט וגם דרך App Store. כדי להימנע מחיוב כפול, בטל אחד מהם."; + +/* Customer Center – support */ +"customer_center_support_subject" = "בקשת תמיכה"; +"customer_center_support_body" = "אנא תאר את הבעיה או השאלה שלך."; +"customer_center_no_mail_app" = "לא הוגדרה אפליקציית אימייל במכשיר זה. תוכל ליצור איתנו קשר בכתובת %@."; +"customer_center_detail_nothing_to_manage" = "אין מה לנהל ברכישה זו."; +"customer_center_detail_managed_through" = "נהלו את המינוי הזה דרך %@."; +"customer_center_detail_managed_where_bought" = "נהלו את המינוי הזה במקום שבו רכשתם אותו."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index 8359155e06..45215e8576 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "पूर्ण"; + +/* Customer Center – screens */ +"customer_center_management_title" = "अपनी सदस्यता प्रबंधित करें"; +"customer_center_no_purchases_title" = "कोई सदस्यता नहीं मिली"; +"customer_center_no_purchases_subtitle" = "हम पिछली खरीदारी की जांच कर सकते हैं।"; +"customer_center_close" = "बंद करें"; +"customer_center_done" = "हो गया"; +"customer_center_cancel" = "रद्द करें"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "खरीदारी पुनर्स्थापित करें"; +"customer_center_path_manage_subscription" = "सदस्यता रद्द करें"; +"customer_center_path_manage_subscription_web" = "सदस्यता प्रबंधित करें"; +"customer_center_web_manage_unavailable" = "अपने ईमेल रसीद में दिए गए लिंक से अपनी सदस्यता प्रबंधित करें।"; +"customer_center_path_refund" = "रिफंड का अनुरोध करें"; +"customer_center_path_change_plan" = "प्लान बदलें"; +"customer_center_path_contact_support" = "सहायता से संपर्क करें"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "आप रद्द क्यों कर रहे हैं?"; +"customer_center_survey_too_expensive" = "बहुत महंगा"; +"customer_center_survey_dont_use" = "ऐप का उपयोग नहीं करता"; +"customer_center_survey_bought_by_mistake" = "गलती से खरीदा गया"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "%@ को %@ में नवीनीकृत होगी"; +"customer_center_renews_on" = "%@ को नवीनीकृत होगी"; +"customer_center_expires_on" = "%@ को समाप्त होगी"; +"customer_center_expired_on" = "%@ को समाप्त हो गई"; +"customer_center_free_trial_until" = "%@ तक निःशुल्क ट्रायल"; +"customer_center_billing_issue" = "बिलिंग समस्या – पहुंच बनाए रखने के लिए अपनी भुगतान विधि अपडेट करें"; +"customer_center_lifetime" = "आजीवन एक्सेस"; +"customer_center_revoked" = "रिफंड कर दिया गया"; +"customer_center_purchased_on" = "%@ को खरीदा गया"; +"customer_center_active_via_superwall" = "सक्रिय"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "समाप्त"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "सक्रिय"; +"customer_center_badge_free_trial" = "निःशुल्क ट्रायल"; +"customer_center_badge_cancelled" = "रद्द"; +"customer_center_badge_billing_issue" = "बिलिंग समस्या"; +"customer_center_badge_expired" = "समाप्त"; +"customer_center_badge_revoked" = "रिफंड कर दिया गया"; +"customer_center_badge_lifetime" = "आजीवन"; + +/* Customer Center – stores */ +"customer_center_store_web" = "वेब"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "अन्य"; +"customer_center_family_shared" = "फ़ैमिली शेयरिंग के ज़रिए साझा किया गया"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "सदस्यताएं"; +"customer_center_section_purchases" = "खरीदारी"; +"customer_center_section_actions" = "कार्रवाइयां"; +"customer_center_account_details" = "खाते का विवरण"; +"customer_center_user_id" = "उपयोगकर्ता आईडी"; +"customer_center_copy" = "कॉपी करें"; +"customer_center_copied" = "कॉपी हो गया"; +"customer_center_original_download_date" = "मूल डाउनलोड तिथि"; + +/* Customer Center – restore */ +"customer_center_restoring" = "पुनर्स्थापित हो रहा है…"; +"customer_center_restore_success_title" = "खरीदारी पुनर्स्थापित की गई"; +"customer_center_restore_success_message" = "हमने आपकी पिछली खरीदारी पुनर्स्थापित करके आपके खाते में लागू कर दी है।"; +"customer_center_restore_none_title" = "कोई पिछली खरीदारी नहीं"; +"customer_center_restore_none_message" = "हमें आपके खाते के लिए कोई खरीदारी नहीं मिली। यदि आपको लगता है कि यह एक त्रुटि है, तो कृपया सहायता से संपर्क करें।"; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple को आपका रिफंड अनुरोध मिल गया है।"; +"customer_center_refund_error" = "रिफंड का अनुरोध करते समय कुछ गड़बड़ हुई। कृपया फिर से प्रयास करें।"; + +/* Customer Center – update warning */ +"customer_center_update_title" = "अपडेट उपलब्ध है"; +"customer_center_update_message" = "ऐप का नवीनतम संस्करण डाउनलोड करने से समस्या हल करने में मदद मिल सकती है।"; +"customer_center_update_action" = "अपडेट करें"; +"customer_center_update_continue" = "जारी रखें"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "हो सकता है आपकी डुप्लीकेट सदस्यताएं हों"; +"customer_center_duplicate_message" = "हो सकता है आप वेब और App Store दोनों के माध्यम से सदस्यता ले चुके हों। दोगुना शुल्क लगने से बचने के लिए, उनमें से एक को रद्द कर दें।"; + +/* Customer Center – support */ +"customer_center_support_subject" = "सहायता अनुरोध"; +"customer_center_support_body" = "कृपया अपनी समस्या या प्रश्न का वर्णन करें।"; +"customer_center_no_mail_app" = "इस डिवाइस पर कोई मेल ऐप कॉन्फ़िगर नहीं है। आप हमसे %@ पर संपर्क कर सकते हैं।"; +"customer_center_detail_nothing_to_manage" = "इस खरीद के लिए प्रबंधित करने के लिए कुछ नहीं है।"; +"customer_center_detail_managed_through" = "इस सदस्यता को %@ के माध्यम से प्रबंधित करें।"; +"customer_center_detail_managed_where_bought" = "इस सदस्यता को वहीं प्रबंधित करें जहाँ से आपने इसे खरीदा था।"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index a6c280cd37..6b603a99aa 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Upravljanje pretplatom"; +"customer_center_no_purchases_title" = "Nije pronađena nijedna pretplata"; +"customer_center_no_purchases_subtitle" = "Možemo provjeriti prethodne kupnje."; +"customer_center_close" = "Zatvori"; +"customer_center_done" = "Gotovo"; +"customer_center_cancel" = "Odustani"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Vrati kupnje"; +"customer_center_path_manage_subscription" = "Otkazivanje pretplate"; +"customer_center_path_manage_subscription_web" = "Upravljanje pretplatom"; +"customer_center_web_manage_unavailable" = "Upravljajte pretplatom putem poveznice u računu poslanom e-poštom."; +"customer_center_path_refund" = "Zatraži povrat novca"; +"customer_center_path_change_plan" = "Promijeni plan"; +"customer_center_path_contact_support" = "Kontaktiraj podršku"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Zašto otkazujete?"; +"customer_center_survey_too_expensive" = "Preskupo"; +"customer_center_survey_dont_use" = "Ne koristim aplikaciju"; +"customer_center_survey_bought_by_mistake" = "Kupljeno pogreškom"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Obnavlja se %@ za %@"; +"customer_center_renews_on" = "Obnavlja se %@"; +"customer_center_expires_on" = "Ističe %@"; +"customer_center_expired_on" = "Isteklo %@"; +"customer_center_free_trial_until" = "Besplatno probno razdoblje do %@"; +"customer_center_billing_issue" = "Problem s naplatom – ažurirajte način plaćanja kako biste zadržali pristup"; +"customer_center_lifetime" = "Doživotni pristup"; +"customer_center_revoked" = "Vraćen novac"; +"customer_center_purchased_on" = "Kupljeno %@"; +"customer_center_active_via_superwall" = "Aktivna"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Isteklo"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktivna"; +"customer_center_badge_free_trial" = "Besplatno probno razdoblje"; +"customer_center_badge_cancelled" = "Otkazano"; +"customer_center_badge_billing_issue" = "Problem s naplatom"; +"customer_center_badge_expired" = "Isteklo"; +"customer_center_badge_revoked" = "Vraćen novac"; +"customer_center_badge_lifetime" = "Doživotno"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Ostalo"; +"customer_center_family_shared" = "Dijeljeno putem Obiteljskog dijeljenja"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Pretplate"; +"customer_center_section_purchases" = "Kupnje"; +"customer_center_section_actions" = "Radnje"; +"customer_center_account_details" = "Pojedinosti računa"; +"customer_center_user_id" = "ID korisnika"; +"customer_center_copy" = "Kopiraj"; +"customer_center_copied" = "Kopirano"; +"customer_center_original_download_date" = "Izvorni datum preuzimanja"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Vraćanje…"; +"customer_center_restore_success_title" = "Kupnje vraćene"; +"customer_center_restore_success_message" = "Vratili smo vaše prethodne kupnje i primijenili ih na vaš račun."; +"customer_center_restore_none_title" = "Nema prethodnih kupnji"; +"customer_center_restore_none_message" = "Nismo pronašli nijednu kupnju za vaš račun. Ako mislite da je ovo greška, obratite se podršci."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple je primio vaš zahtjev za povrat novca."; +"customer_center_refund_error" = "Došlo je do pogreške prilikom zahtjeva za povrat novca. Pokušajte ponovno."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Dostupno je ažuriranje"; +"customer_center_update_message" = "Preuzimanje najnovije verzije aplikacije moglo bi pomoći u rješavanju problema."; +"customer_center_update_action" = "Ažuriraj"; +"customer_center_update_continue" = "Nastavi"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Možda imate duplicirane pretplate"; +"customer_center_duplicate_message" = "Možda ste pretplaćeni i putem weba i putem App Storea. Kako biste izbjegli dvostruku naplatu, otkažite jednu od njih."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Zahtjev za podršku"; +"customer_center_support_body" = "Opišite svoj problem ili pitanje."; +"customer_center_no_mail_app" = "Na ovom uređaju nije postavljena aplikacija za e-poštu. Možete nas kontaktirati na %@."; +"customer_center_detail_nothing_to_manage" = "Za ovu kupnju nema ničega za upravljanje."; +"customer_center_detail_managed_through" = "Upravljajte ovom pretplatom putem %@."; +"customer_center_detail_managed_where_bought" = "Upravljajte ovom pretplatom tamo gdje ste je kupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index 1309af5509..6afef55a14 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Kész"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Előfizetés kezelése"; +"customer_center_no_purchases_title" = "Nem található előfizetés"; +"customer_center_no_purchases_subtitle" = "Ellenőrizhetjük a korábbi vásárlásokat."; +"customer_center_close" = "Bezárás"; +"customer_center_done" = "Kész"; +"customer_center_cancel" = "Mégse"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Vásárlások visszaállítása"; +"customer_center_path_manage_subscription" = "Előfizetés lemondása"; +"customer_center_path_manage_subscription_web" = "Előfizetés kezelése"; +"customer_center_web_manage_unavailable" = "Kezelje előfizetését az e-mailben kapott nyugtában található hivatkozással."; +"customer_center_path_refund" = "Visszatérítés kérése"; +"customer_center_path_change_plan" = "Csomag módosítása"; +"customer_center_path_contact_support" = "Kapcsolatfelvétel az ügyfélszolgálattal"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Miért mondja le?"; +"customer_center_survey_too_expensive" = "Túl drága"; +"customer_center_survey_dont_use" = "Nem használom az alkalmazást"; +"customer_center_survey_bought_by_mistake" = "Tévedésből vásároltam"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Megújul: %@, ár: %@"; +"customer_center_renews_on" = "Megújul: %@"; +"customer_center_expires_on" = "Lejár: %@"; +"customer_center_expired_on" = "Lejárt: %@"; +"customer_center_free_trial_until" = "Ingyenes próba eddig: %@"; +"customer_center_billing_issue" = "Számlázási probléma – a hozzáférés megtartásához frissítse a fizetési módot"; +"customer_center_lifetime" = "Élettartam hozzáférés"; +"customer_center_revoked" = "Visszatérítve"; +"customer_center_purchased_on" = "Vásárolva: %@"; +"customer_center_active_via_superwall" = "Aktív"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Lejárt"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktív"; +"customer_center_badge_free_trial" = "Ingyenes próba"; +"customer_center_badge_cancelled" = "Lemondva"; +"customer_center_badge_billing_issue" = "Számlázási probléma"; +"customer_center_badge_expired" = "Lejárt"; +"customer_center_badge_revoked" = "Visszatérítve"; +"customer_center_badge_lifetime" = "Élettartam"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Egyéb"; +"customer_center_family_shared" = "Megosztva Családmegosztáson keresztül"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Előfizetések"; +"customer_center_section_purchases" = "Vásárlások"; +"customer_center_section_actions" = "Műveletek"; +"customer_center_account_details" = "Fiók adatai"; +"customer_center_user_id" = "Felhasználói azonosító"; +"customer_center_copy" = "Másolás"; +"customer_center_copied" = "Másolva"; +"customer_center_original_download_date" = "Eredeti letöltés dátuma"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Visszaállítás…"; +"customer_center_restore_success_title" = "Vásárlások visszaállítva"; +"customer_center_restore_success_message" = "Visszaállítottuk korábbi vásárlásait, és alkalmaztuk azokat a fiókjára."; +"customer_center_restore_none_title" = "Nincsenek korábbi vásárlások"; +"customer_center_restore_none_message" = "Nem találtunk vásárlást a fiókjához. Ha úgy gondolja, hogy ez hiba, forduljon az ügyfélszolgálathoz."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Az Apple megkapta a visszatérítési kérelmét."; +"customer_center_refund_error" = "Hiba történt a visszatérítés kérése közben. Kérjük, próbálja újra."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Frissítés elérhető"; +"customer_center_update_message" = "Az alkalmazás legújabb verziójának letöltése segíthet a probléma megoldásában."; +"customer_center_update_action" = "Frissítés"; +"customer_center_update_continue" = "Folytatás"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Lehet, hogy duplikált előfizetései vannak"; +"customer_center_duplicate_message" = "Előfordulhat, hogy egyszerre fizet elő a weben és az App Store-on keresztül is. A kétszeres terhelés elkerülése érdekében mondja le az egyiket."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Támogatási kérelem"; +"customer_center_support_body" = "Kérjük, írja le a problémáját vagy kérdését."; +"customer_center_no_mail_app" = "Ezen az eszközön nincs beállítva levelezőalkalmazás. Elérhet minket a következő címen: %@."; +"customer_center_detail_nothing_to_manage" = "Ehhez a vásárláshoz nincs mit kezelni."; +"customer_center_detail_managed_through" = "Kezeld ezt az előfizetést itt: %@."; +"customer_center_detail_managed_where_bought" = "Kezeld ezt az előfizetést ott, ahol megvásároltad."; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index 6e2fad778e..c5e24f7e68 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Selesai"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Kelola langganan Anda"; +"customer_center_no_purchases_title" = "Tidak ada langganan yang ditemukan"; +"customer_center_no_purchases_subtitle" = "Kami dapat memeriksa pembelian sebelumnya."; +"customer_center_close" = "Tutup"; +"customer_center_done" = "Selesai"; +"customer_center_cancel" = "Batal"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Pulihkan pembelian"; +"customer_center_path_manage_subscription" = "Batalkan langganan"; +"customer_center_path_manage_subscription_web" = "Kelola langganan"; +"customer_center_web_manage_unavailable" = "Kelola langganan Anda melalui tautan di tanda terima email Anda."; +"customer_center_path_refund" = "Ajukan pengembalian dana"; +"customer_center_path_change_plan" = "Ubah paket"; +"customer_center_path_contact_support" = "Hubungi dukungan"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Mengapa Anda membatalkan?"; +"customer_center_survey_too_expensive" = "Terlalu mahal"; +"customer_center_survey_dont_use" = "Tidak menggunakan aplikasi"; +"customer_center_survey_bought_by_mistake" = "Terbeli tanpa sengaja"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Diperbarui pada %@ seharga %@"; +"customer_center_renews_on" = "Diperbarui pada %@"; +"customer_center_expires_on" = "Berakhir pada %@"; +"customer_center_expired_on" = "Berakhir pada %@"; +"customer_center_free_trial_until" = "Uji coba gratis hingga %@"; +"customer_center_billing_issue" = "Masalah penagihan – perbarui metode pembayaran Anda untuk mempertahankan akses"; +"customer_center_lifetime" = "Akses seumur hidup"; +"customer_center_revoked" = "Dana dikembalikan"; +"customer_center_purchased_on" = "Dibeli pada %@"; +"customer_center_active_via_superwall" = "Aktif"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Berakhir"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktif"; +"customer_center_badge_free_trial" = "Uji coba gratis"; +"customer_center_badge_cancelled" = "Dibatalkan"; +"customer_center_badge_billing_issue" = "Masalah penagihan"; +"customer_center_badge_expired" = "Berakhir"; +"customer_center_badge_revoked" = "Dana dikembalikan"; +"customer_center_badge_lifetime" = "Seumur hidup"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Lainnya"; +"customer_center_family_shared" = "Dibagikan melalui Berbagi Keluarga"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Langganan"; +"customer_center_section_purchases" = "Pembelian"; +"customer_center_section_actions" = "Tindakan"; +"customer_center_account_details" = "Detail akun"; +"customer_center_user_id" = "ID pengguna"; +"customer_center_copy" = "Salin"; +"customer_center_copied" = "Disalin"; +"customer_center_original_download_date" = "Tanggal unduhan asli"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Memulihkan…"; +"customer_center_restore_success_title" = "Pembelian dipulihkan"; +"customer_center_restore_success_message" = "Kami telah memulihkan pembelian Anda sebelumnya dan menerapkannya ke akun Anda."; +"customer_center_restore_none_title" = "Tidak ada pembelian sebelumnya"; +"customer_center_restore_none_message" = "Kami tidak dapat menemukan pembelian untuk akun Anda. Jika Anda merasa ini adalah kesalahan, silakan hubungi dukungan."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple telah menerima permintaan pengembalian dana Anda."; +"customer_center_refund_error" = "Terjadi kesalahan saat meminta pengembalian dana. Silakan coba lagi."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Pembaruan tersedia"; +"customer_center_update_message" = "Mengunduh versi terbaru aplikasi dapat membantu mengatasi masalah ini."; +"customer_center_update_action" = "Perbarui"; +"customer_center_update_continue" = "Lanjutkan"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Anda mungkin memiliki langganan duplikat"; +"customer_center_duplicate_message" = "Anda mungkin berlangganan baik melalui web maupun App Store. Untuk menghindari penagihan dua kali, batalkan salah satunya."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Permintaan dukungan"; +"customer_center_support_body" = "Silakan jelaskan masalah atau pertanyaan Anda."; +"customer_center_no_mail_app" = "Tidak ada aplikasi email yang dikonfigurasi di perangkat ini. Anda dapat menghubungi kami di %@."; +"customer_center_detail_nothing_to_manage" = "Tidak ada yang perlu dikelola untuk pembelian ini."; +"customer_center_detail_managed_through" = "Kelola langganan ini melalui %@."; +"customer_center_detail_managed_where_bought" = "Kelola langganan ini di tempat Anda membelinya."; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index bed208f7dd..1d13b652cf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fine"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestisci il tuo abbonamento"; +"customer_center_no_purchases_title" = "Nessun abbonamento trovato"; +"customer_center_no_purchases_subtitle" = "Possiamo verificare la presenza di acquisti precedenti."; +"customer_center_close" = "Chiudi"; +"customer_center_done" = "Fatto"; +"customer_center_cancel" = "Annulla"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Ripristina acquisti"; +"customer_center_path_manage_subscription" = "Disdici abbonamento"; +"customer_center_path_manage_subscription_web" = "Gestisci abbonamento"; +"customer_center_web_manage_unavailable" = "Gestisci il tuo abbonamento tramite il link nella ricevuta via e-mail."; +"customer_center_path_refund" = "Richiedi un rimborso"; +"customer_center_path_change_plan" = "Cambia piano"; +"customer_center_path_contact_support" = "Contatta l'assistenza"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Perché stai annullando?"; +"customer_center_survey_too_expensive" = "Troppo costoso"; +"customer_center_survey_dont_use" = "Non uso l'app"; +"customer_center_survey_bought_by_mistake" = "Acquistato per errore"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Si rinnova il %@ per %@"; +"customer_center_renews_on" = "Si rinnova il %@"; +"customer_center_expires_on" = "Scade il %@"; +"customer_center_expired_on" = "Scaduto il %@"; +"customer_center_free_trial_until" = "Prova gratuita fino al %@"; +"customer_center_billing_issue" = "Problema di fatturazione – aggiorna il tuo metodo di pagamento per mantenere l'accesso"; +"customer_center_lifetime" = "Accesso a vita"; +"customer_center_revoked" = "Rimborsato"; +"customer_center_purchased_on" = "Acquistato il %@"; +"customer_center_active_via_superwall" = "Attivo"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Scaduto"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Attivo"; +"customer_center_badge_free_trial" = "Prova gratuita"; +"customer_center_badge_cancelled" = "Annullato"; +"customer_center_badge_billing_issue" = "Problema di fatturazione"; +"customer_center_badge_expired" = "Scaduto"; +"customer_center_badge_revoked" = "Rimborsato"; +"customer_center_badge_lifetime" = "A vita"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Altro"; +"customer_center_family_shared" = "Condiviso tramite In famiglia"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abbonamenti"; +"customer_center_section_purchases" = "Acquisti"; +"customer_center_section_actions" = "Azioni"; +"customer_center_account_details" = "Dettagli account"; +"customer_center_user_id" = "ID utente"; +"customer_center_copy" = "Copia"; +"customer_center_copied" = "Copiato"; +"customer_center_original_download_date" = "Data di download originale"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Ripristino in corso…"; +"customer_center_restore_success_title" = "Acquisti ripristinati"; +"customer_center_restore_success_message" = "Abbiamo ripristinato i tuoi acquisti precedenti e li abbiamo applicati al tuo account."; +"customer_center_restore_none_title" = "Nessun acquisto precedente"; +"customer_center_restore_none_message" = "Non abbiamo trovato acquisti per il tuo account. Se ritieni che si tratti di un errore, contatta l'assistenza."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple ha ricevuto la tua richiesta di rimborso."; +"customer_center_refund_error" = "Si è verificato un errore durante la richiesta di rimborso. Riprova."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Aggiornamento disponibile"; +"customer_center_update_message" = "Scaricare l'ultima versione dell'app potrebbe aiutare a risolvere il problema."; +"customer_center_update_action" = "Aggiorna"; +"customer_center_update_continue" = "Continua"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Potresti avere abbonamenti duplicati"; +"customer_center_duplicate_message" = "Potresti essere abbonato sia sul web sia tramite l'App Store. Per evitare un doppio addebito, annullane uno."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Richiesta di assistenza"; +"customer_center_support_body" = "Descrivi il tuo problema o la tua domanda."; +"customer_center_no_mail_app" = "Su questo dispositivo non è configurata alcuna app di posta. Puoi contattarci all'indirizzo %@."; +"customer_center_detail_nothing_to_manage" = "Non c'è nulla da gestire per questo acquisto."; +"customer_center_detail_managed_through" = "Gestisci questo abbonamento tramite %@."; +"customer_center_detail_managed_where_bought" = "Gestisci questo abbonamento dove l'hai acquistato."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 532a154a5c..94f0748671 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "完了"; + +/* Customer Center – screens */ +"customer_center_management_title" = "サブスクリプションを管理"; +"customer_center_no_purchases_title" = "サブスクリプションが見つかりません"; +"customer_center_no_purchases_subtitle" = "以前の購入を確認できます。"; +"customer_center_close" = "閉じる"; +"customer_center_done" = "完了"; +"customer_center_cancel" = "キャンセル"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "購入を復元"; +"customer_center_path_manage_subscription" = "サブスクリプションを解約"; +"customer_center_path_manage_subscription_web" = "サブスクリプションを管理"; +"customer_center_web_manage_unavailable" = "メールの領収書に記載されたリンクからサブスクリプションを管理できます。"; +"customer_center_path_refund" = "返金をリクエスト"; +"customer_center_path_change_plan" = "プランを変更"; +"customer_center_path_contact_support" = "サポートに問い合わせる"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "キャンセルする理由を教えてください"; +"customer_center_survey_too_expensive" = "料金が高すぎる"; +"customer_center_survey_dont_use" = "アプリを使っていない"; +"customer_center_survey_bought_by_mistake" = "誤って購入した"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "%@に%@で更新されます"; +"customer_center_renews_on" = "%@に更新されます"; +"customer_center_expires_on" = "%@に終了します"; +"customer_center_expired_on" = "%@に終了しました"; +"customer_center_free_trial_until" = "%@まで無料トライアル"; +"customer_center_billing_issue" = "お支払いに問題があります – アクセスを維持するには支払い方法を更新してください"; +"customer_center_lifetime" = "生涯アクセス"; +"customer_center_revoked" = "返金済み"; +"customer_center_purchased_on" = "%@に購入"; +"customer_center_active_via_superwall" = "有効"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "終了しました"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "有効"; +"customer_center_badge_free_trial" = "無料トライアル"; +"customer_center_badge_cancelled" = "キャンセル済み"; +"customer_center_badge_billing_issue" = "お支払いの問題"; +"customer_center_badge_expired" = "終了"; +"customer_center_badge_revoked" = "返金済み"; +"customer_center_badge_lifetime" = "生涯"; + +/* Customer Center – stores */ +"customer_center_store_web" = "ウェブ"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "その他"; +"customer_center_family_shared" = "ファミリー共有経由で共有中"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "サブスクリプション"; +"customer_center_section_purchases" = "購入"; +"customer_center_section_actions" = "操作"; +"customer_center_account_details" = "アカウントの詳細"; +"customer_center_user_id" = "ユーザーID"; +"customer_center_copy" = "コピー"; +"customer_center_copied" = "コピーしました"; +"customer_center_original_download_date" = "初回ダウンロード日"; + +/* Customer Center – restore */ +"customer_center_restoring" = "復元中…"; +"customer_center_restore_success_title" = "購入を復元しました"; +"customer_center_restore_success_message" = "過去の購入を復元し、アカウントに適用しました。"; +"customer_center_restore_none_title" = "過去の購入はありません"; +"customer_center_restore_none_message" = "アカウントに購入履歴が見つかりませんでした。誤りだと思われる場合は、サポートにお問い合わせください。"; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Appleが返金リクエストを受け付けました。"; +"customer_center_refund_error" = "返金のリクエスト中に問題が発生しました。もう一度お試しください。"; + +/* Customer Center – update warning */ +"customer_center_update_title" = "アップデートが利用可能です"; +"customer_center_update_message" = "アプリの最新バージョンをダウンロードすると、問題の解決に役立つ場合があります。"; +"customer_center_update_action" = "アップデート"; +"customer_center_update_continue" = "続ける"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "サブスクリプションが重複している可能性があります"; +"customer_center_duplicate_message" = "ウェブとApp Storeの両方でサブスクリプションに登録している可能性があります。二重請求を避けるため、いずれか一方をキャンセルしてください。"; + +/* Customer Center – support */ +"customer_center_support_subject" = "サポートリクエスト"; +"customer_center_support_body" = "問題やご質問の内容をご記入ください。"; +"customer_center_no_mail_app" = "このデバイスにはメールアプリが設定されていません。%@までご連絡ください。"; +"customer_center_detail_nothing_to_manage" = "この購入には管理できる項目がありません。"; +"customer_center_detail_managed_through" = "このサブスクリプションは%@で管理してください。"; +"customer_center_detail_managed_where_bought" = "このサブスクリプションは購入元で管理してください。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 88ec351edc..f12dba15bb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "완료"; + +/* Customer Center – screens */ +"customer_center_management_title" = "구독 관리"; +"customer_center_no_purchases_title" = "구독을 찾을 수 없습니다"; +"customer_center_no_purchases_subtitle" = "이전 구매 내역을 확인할 수 있습니다."; +"customer_center_close" = "닫기"; +"customer_center_done" = "완료"; +"customer_center_cancel" = "취소"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "구매 항목 복원"; +"customer_center_path_manage_subscription" = "구독 취소"; +"customer_center_path_manage_subscription_web" = "구독 관리"; +"customer_center_web_manage_unavailable" = "이메일 영수증의 링크에서 구독을 관리하세요."; +"customer_center_path_refund" = "환불 요청"; +"customer_center_path_change_plan" = "요금제 변경"; +"customer_center_path_contact_support" = "지원팀에 문의"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "취소하시는 이유가 무엇인가요?"; +"customer_center_survey_too_expensive" = "너무 비쌈"; +"customer_center_survey_dont_use" = "앱을 사용하지 않음"; +"customer_center_survey_bought_by_mistake" = "실수로 구매함"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "%@에 %@에 갱신됩니다"; +"customer_center_renews_on" = "%@에 갱신됩니다"; +"customer_center_expires_on" = "%@에 만료됩니다"; +"customer_center_expired_on" = "%@에 만료되었습니다"; +"customer_center_free_trial_until" = "%@까지 무료 체험"; +"customer_center_billing_issue" = "결제 문제 – 계속 이용하려면 결제 수단을 업데이트하세요"; +"customer_center_lifetime" = "평생 이용 가능"; +"customer_center_revoked" = "환불됨"; +"customer_center_purchased_on" = "%@에 구매함"; +"customer_center_active_via_superwall" = "활성"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "만료됨"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "활성"; +"customer_center_badge_free_trial" = "무료 체험"; +"customer_center_badge_cancelled" = "취소됨"; +"customer_center_badge_billing_issue" = "결제 문제"; +"customer_center_badge_expired" = "만료됨"; +"customer_center_badge_revoked" = "환불됨"; +"customer_center_badge_lifetime" = "평생"; + +/* Customer Center – stores */ +"customer_center_store_web" = "웹"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "기타"; +"customer_center_family_shared" = "가족 공유를 통해 공유됨"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "구독"; +"customer_center_section_purchases" = "구매 항목"; +"customer_center_section_actions" = "작업"; +"customer_center_account_details" = "계정 세부정보"; +"customer_center_user_id" = "사용자 ID"; +"customer_center_copy" = "복사"; +"customer_center_copied" = "복사됨"; +"customer_center_original_download_date" = "최초 다운로드 날짜"; + +/* Customer Center – restore */ +"customer_center_restoring" = "복원 중…"; +"customer_center_restore_success_title" = "구매 항목이 복원되었습니다"; +"customer_center_restore_success_message" = "이전 구매 항목을 복원하여 계정에 적용했습니다."; +"customer_center_restore_none_title" = "이전 구매 내역 없음"; +"customer_center_restore_none_message" = "계정에서 구매 내역을 찾을 수 없습니다. 오류라고 생각되면 지원팀에 문의해 주세요."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple이 환불 요청을 접수했습니다."; +"customer_center_refund_error" = "환불을 요청하는 중 문제가 발생했습니다. 다시 시도해 주세요."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "업데이트 사용 가능"; +"customer_center_update_message" = "최신 버전의 앱을 다운로드하면 문제 해결에 도움이 될 수 있습니다."; +"customer_center_update_action" = "업데이트"; +"customer_center_update_continue" = "계속"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "중복 구독이 있을 수 있습니다"; +"customer_center_duplicate_message" = "웹과 App Store 모두에서 구독 중일 수 있습니다. 이중 청구를 방지하려면 둘 중 하나를 취소하세요."; + +/* Customer Center – support */ +"customer_center_support_subject" = "지원 요청"; +"customer_center_support_body" = "문제나 질문을 설명해 주세요."; +"customer_center_no_mail_app" = "이 기기에 메일 앱이 설정되어 있지 않습니다. %@로 문의해 주세요."; +"customer_center_detail_nothing_to_manage" = "이 구매에는 관리할 항목이 없습니다."; +"customer_center_detail_managed_through" = "이 구독은 %@에서 관리하세요."; +"customer_center_detail_managed_where_bought" = "이 구독은 구매한 곳에서 관리하세요."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 97168b3253..35ea16741a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Selesai"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Urus langganan anda"; +"customer_center_no_purchases_title" = "Tiada langganan ditemui"; +"customer_center_no_purchases_subtitle" = "Kami boleh menyemak pembelian terdahulu."; +"customer_center_close" = "Tutup"; +"customer_center_done" = "Selesai"; +"customer_center_cancel" = "Batal"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Pulihkan pembelian"; +"customer_center_path_manage_subscription" = "Batalkan langganan"; +"customer_center_path_manage_subscription_web" = "Urus langganan"; +"customer_center_web_manage_unavailable" = "Urus langganan anda melalui pautan dalam resit e-mel anda."; +"customer_center_path_refund" = "Mohon bayaran balik"; +"customer_center_path_change_plan" = "Tukar pelan"; +"customer_center_path_contact_support" = "Hubungi sokongan"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Kenapa anda membatalkan?"; +"customer_center_survey_too_expensive" = "Terlalu mahal"; +"customer_center_survey_dont_use" = "Tidak menggunakan aplikasi"; +"customer_center_survey_bought_by_mistake" = "Dibeli secara tidak sengaja"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Diperbaharui pada %@ dengan harga %@"; +"customer_center_renews_on" = "Diperbaharui pada %@"; +"customer_center_expires_on" = "Tamat tempoh pada %@"; +"customer_center_expired_on" = "Telah tamat tempoh pada %@"; +"customer_center_free_trial_until" = "Percubaan percuma sehingga %@"; +"customer_center_billing_issue" = "Masalah pengebilan – kemas kini kaedah pembayaran anda untuk mengekalkan akses"; +"customer_center_lifetime" = "Akses seumur hidup"; +"customer_center_revoked" = "Dibayar balik"; +"customer_center_purchased_on" = "Dibeli pada %@"; +"customer_center_active_via_superwall" = "Aktif"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Tamat tempoh"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktif"; +"customer_center_badge_free_trial" = "Percubaan percuma"; +"customer_center_badge_cancelled" = "Dibatalkan"; +"customer_center_badge_billing_issue" = "Masalah pengebilan"; +"customer_center_badge_expired" = "Tamat tempoh"; +"customer_center_badge_revoked" = "Dibayar balik"; +"customer_center_badge_lifetime" = "Seumur hidup"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Lain-lain"; +"customer_center_family_shared" = "Dikongsi melalui Perkongsian Keluarga"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Langganan"; +"customer_center_section_purchases" = "Pembelian"; +"customer_center_section_actions" = "Tindakan"; +"customer_center_account_details" = "Butiran akaun"; +"customer_center_user_id" = "ID pengguna"; +"customer_center_copy" = "Salin"; +"customer_center_copied" = "Disalin"; +"customer_center_original_download_date" = "Tarikh muat turun asal"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Memulihkan…"; +"customer_center_restore_success_title" = "Pembelian dipulihkan"; +"customer_center_restore_success_message" = "Kami telah memulihkan pembelian lalu anda dan menggunakannya pada akaun anda."; +"customer_center_restore_none_title" = "Tiada pembelian lalu"; +"customer_center_restore_none_message" = "Kami tidak dapat menemui sebarang pembelian untuk akaun anda. Jika anda rasa ini satu kesilapan, sila hubungi sokongan."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple telah menerima permohonan bayaran balik anda."; +"customer_center_refund_error" = "Sesuatu tidak kena semasa memohon bayaran balik. Sila cuba lagi."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Kemas kini tersedia"; +"customer_center_update_message" = "Memuat turun versi terkini aplikasi mungkin membantu menyelesaikan masalah ini."; +"customer_center_update_action" = "Kemas kini"; +"customer_center_update_continue" = "Teruskan"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Anda mungkin mempunyai langganan berganda"; +"customer_center_duplicate_message" = "Anda mungkin melanggan melalui web dan App Store pada masa yang sama. Untuk mengelakkan caj berganda, batalkan salah satu daripadanya."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Permohonan sokongan"; +"customer_center_support_body" = "Sila terangkan masalah atau soalan anda."; +"customer_center_no_mail_app" = "Tiada aplikasi mel dikonfigurasikan pada peranti ini. Anda boleh menghubungi kami di %@."; +"customer_center_detail_nothing_to_manage" = "Tiada apa-apa untuk diurus bagi pembelian ini."; +"customer_center_detail_managed_through" = "Urus langganan ini melalui %@."; +"customer_center_detail_managed_where_bought" = "Urus langganan ini di tempat anda membelinya."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index c19d9ce152..4027de83f9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Ferdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer abonnementet ditt"; +"customer_center_no_purchases_title" = "Fant ingen abonnementer"; +"customer_center_no_purchases_subtitle" = "Vi kan sjekke etter tidligere kjøp."; +"customer_center_close" = "Lukk"; +"customer_center_done" = "Ferdig"; +"customer_center_cancel" = "Avbryt"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Gjenopprett kjøp"; +"customer_center_path_manage_subscription" = "Si opp abonnement"; +"customer_center_path_manage_subscription_web" = "Administrer abonnement"; +"customer_center_web_manage_unavailable" = "Administrer abonnementet ditt via lenken i kvitteringen på e-post."; +"customer_center_path_refund" = "Be om refusjon"; +"customer_center_path_change_plan" = "Endre abonnement"; +"customer_center_path_contact_support" = "Kontakt kundestøtte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Hvorfor sier du opp?"; +"customer_center_survey_too_expensive" = "For dyrt"; +"customer_center_survey_dont_use" = "Bruker ikke appen"; +"customer_center_survey_bought_by_mistake" = "Kjøpt ved en feil"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Fornyes %@ for %@"; +"customer_center_renews_on" = "Fornyes %@"; +"customer_center_expires_on" = "Utløper %@"; +"customer_center_expired_on" = "Utløp %@"; +"customer_center_free_trial_until" = "Gratis prøveperiode til %@"; +"customer_center_billing_issue" = "Betalingsproblem – oppdater betalingsmåten din for å beholde tilgangen"; +"customer_center_lifetime" = "Livstidstilgang"; +"customer_center_revoked" = "Refundert"; +"customer_center_purchased_on" = "Kjøpt %@"; +"customer_center_active_via_superwall" = "Aktiv"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Utløpt"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiv"; +"customer_center_badge_free_trial" = "Gratis prøveperiode"; +"customer_center_badge_cancelled" = "Sagt opp"; +"customer_center_badge_billing_issue" = "Betalingsproblem"; +"customer_center_badge_expired" = "Utløpt"; +"customer_center_badge_revoked" = "Refundert"; +"customer_center_badge_lifetime" = "Livstid"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Nett"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Annet"; +"customer_center_family_shared" = "Delt via Familiedeling"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnementer"; +"customer_center_section_purchases" = "Kjøp"; +"customer_center_section_actions" = "Handlinger"; +"customer_center_account_details" = "Kontodetaljer"; +"customer_center_user_id" = "Bruker-ID"; +"customer_center_copy" = "Kopier"; +"customer_center_copied" = "Kopiert"; +"customer_center_original_download_date" = "Opprinnelig nedlastingsdato"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Gjenoppretter…"; +"customer_center_restore_success_title" = "Kjøp gjenopprettet"; +"customer_center_restore_success_message" = "Vi har gjenopprettet dine tidligere kjøp og lagt dem til kontoen din."; +"customer_center_restore_none_title" = "Ingen tidligere kjøp"; +"customer_center_restore_none_message" = "Vi fant ingen kjøp for kontoen din. Hvis du tror dette er en feil, kan du kontakte kundestøtte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple har mottatt forespørselen din om refusjon."; +"customer_center_refund_error" = "Noe gikk galt under forespørselen om refusjon. Prøv igjen."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Oppdatering tilgjengelig"; +"customer_center_update_message" = "Å laste ned den nyeste versjonen av appen kan bidra til å løse problemet."; +"customer_center_update_action" = "Oppdater"; +"customer_center_update_continue" = "Fortsett"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Du har kanskje doble abonnementer"; +"customer_center_duplicate_message" = "Du er kanskje abonnent både på nettet og via App Store. For å unngå å bli belastet to ganger, kan du si opp ett av dem."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support-forespørsel"; +"customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; +"customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "Det er ingenting å administrere for dette kjøpet."; +"customer_center_detail_managed_through" = "Administrer dette abonnementet via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnementet der du kjøpte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index 326532972a..abedd588f6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gereed"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Beheer uw abonnement"; +"customer_center_no_purchases_title" = "Geen abonnementen gevonden"; +"customer_center_no_purchases_subtitle" = "We kunnen controleren op eerdere aankopen."; +"customer_center_close" = "Sluiten"; +"customer_center_done" = "Gereed"; +"customer_center_cancel" = "Annuleren"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Aankopen herstellen"; +"customer_center_path_manage_subscription" = "Abonnement opzeggen"; +"customer_center_path_manage_subscription_web" = "Abonnement beheren"; +"customer_center_web_manage_unavailable" = "Beheer je abonnement via de link in je e-mailbevestiging."; +"customer_center_path_refund" = "Terugbetaling aanvragen"; +"customer_center_path_change_plan" = "Abonnement wijzigen"; +"customer_center_path_contact_support" = "Contact opnemen met ondersteuning"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Waarom zegt u op?"; +"customer_center_survey_too_expensive" = "Te duur"; +"customer_center_survey_dont_use" = "Gebruik de app niet"; +"customer_center_survey_bought_by_mistake" = "Per ongeluk gekocht"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Wordt verlengd op %@ voor %@"; +"customer_center_renews_on" = "Wordt verlengd op %@"; +"customer_center_expires_on" = "Verloopt op %@"; +"customer_center_expired_on" = "Verlopen op %@"; +"customer_center_free_trial_until" = "Gratis proefperiode tot %@"; +"customer_center_billing_issue" = "Factureringsprobleem – werk uw betaalmethode bij om toegang te behouden"; +"customer_center_lifetime" = "Levenslange toegang"; +"customer_center_revoked" = "Terugbetaald"; +"customer_center_purchased_on" = "Gekocht op %@"; +"customer_center_active_via_superwall" = "Actief"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Verlopen"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Actief"; +"customer_center_badge_free_trial" = "Gratis proefperiode"; +"customer_center_badge_cancelled" = "Opgezegd"; +"customer_center_badge_billing_issue" = "Factureringsprobleem"; +"customer_center_badge_expired" = "Verlopen"; +"customer_center_badge_revoked" = "Terugbetaald"; +"customer_center_badge_lifetime" = "Levenslang"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Overig"; +"customer_center_family_shared" = "Gedeeld via Gezinsdeling"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnementen"; +"customer_center_section_purchases" = "Aankopen"; +"customer_center_section_actions" = "Acties"; +"customer_center_account_details" = "Accountgegevens"; +"customer_center_user_id" = "Gebruikers-ID"; +"customer_center_copy" = "Kopiëren"; +"customer_center_copied" = "Gekopieerd"; +"customer_center_original_download_date" = "Oorspronkelijke downloaddatum"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Bezig met herstellen…"; +"customer_center_restore_success_title" = "Aankopen hersteld"; +"customer_center_restore_success_message" = "We hebben uw eerdere aankopen hersteld en toegepast op uw account."; +"customer_center_restore_none_title" = "Geen eerdere aankopen"; +"customer_center_restore_none_message" = "We konden geen aankopen vinden voor uw account. Als u denkt dat dit een fout is, neem dan contact op met ondersteuning."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple heeft uw terugbetalingsverzoek ontvangen."; +"customer_center_refund_error" = "Er is iets misgegaan bij het aanvragen van een terugbetaling. Probeer het opnieuw."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Update beschikbaar"; +"customer_center_update_message" = "Het downloaden van de nieuwste versie van de app kan helpen het probleem op te lossen."; +"customer_center_update_action" = "Bijwerken"; +"customer_center_update_continue" = "Doorgaan"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "U hebt mogelijk dubbele abonnementen"; +"customer_center_duplicate_message" = "Mogelijk bent u zowel via het web als via de App Store geabonneerd. Om dubbele kosten te voorkomen, kunt u er een opzeggen."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Ondersteuningsverzoek"; +"customer_center_support_body" = "Beschrijf uw probleem of vraag."; +"customer_center_no_mail_app" = "Er is geen mail-app geconfigureerd op dit apparaat. U kunt ons bereiken via %@."; +"customer_center_detail_nothing_to_manage" = "Er valt niets te beheren voor deze aankoop."; +"customer_center_detail_managed_through" = "Beheer dit abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Beheer dit abonnement waar je het hebt gekocht."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index b65eb3c5c5..e1618fb05e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Ferdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer abonnementet ditt"; +"customer_center_no_purchases_title" = "Fant ingen abonnementer"; +"customer_center_no_purchases_subtitle" = "Vi kan sjekke etter tidligere kjøp."; +"customer_center_close" = "Lukk"; +"customer_center_done" = "Ferdig"; +"customer_center_cancel" = "Avbryt"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Gjenopprett kjøp"; +"customer_center_path_manage_subscription" = "Sei opp abonnement"; +"customer_center_path_manage_subscription_web" = "Administrer abonnement"; +"customer_center_web_manage_unavailable" = "Administrer abonnementet ditt via lenkja i kvitteringa på e-post."; +"customer_center_path_refund" = "Be om refusjon"; +"customer_center_path_change_plan" = "Endre abonnement"; +"customer_center_path_contact_support" = "Kontakt kundestøtte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Hvorfor sier du opp?"; +"customer_center_survey_too_expensive" = "For dyrt"; +"customer_center_survey_dont_use" = "Bruker ikke appen"; +"customer_center_survey_bought_by_mistake" = "Kjøpt ved en feil"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Fornyes %@ for %@"; +"customer_center_renews_on" = "Fornyes %@"; +"customer_center_expires_on" = "Utløper %@"; +"customer_center_expired_on" = "Utløp %@"; +"customer_center_free_trial_until" = "Gratis prøveperiode til %@"; +"customer_center_billing_issue" = "Betalingsproblem – oppdater betalingsmåten din for å beholde tilgangen"; +"customer_center_lifetime" = "Livstidstilgang"; +"customer_center_revoked" = "Refundert"; +"customer_center_purchased_on" = "Kjøpt %@"; +"customer_center_active_via_superwall" = "Aktiv"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Utløpt"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiv"; +"customer_center_badge_free_trial" = "Gratis prøveperiode"; +"customer_center_badge_cancelled" = "Sagt opp"; +"customer_center_badge_billing_issue" = "Betalingsproblem"; +"customer_center_badge_expired" = "Utløpt"; +"customer_center_badge_revoked" = "Refundert"; +"customer_center_badge_lifetime" = "Livstid"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Nett"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Annet"; +"customer_center_family_shared" = "Delt via Familiedeling"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonnementer"; +"customer_center_section_purchases" = "Kjøp"; +"customer_center_section_actions" = "Handlinger"; +"customer_center_account_details" = "Kontodetaljer"; +"customer_center_user_id" = "Bruker-ID"; +"customer_center_copy" = "Kopier"; +"customer_center_copied" = "Kopiert"; +"customer_center_original_download_date" = "Opprinnelig nedlastingsdato"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Gjenoppretter…"; +"customer_center_restore_success_title" = "Kjøp gjenopprettet"; +"customer_center_restore_success_message" = "Vi har gjenopprettet dine tidligere kjøp og lagt dem til kontoen din."; +"customer_center_restore_none_title" = "Ingen tidligere kjøp"; +"customer_center_restore_none_message" = "Vi fant ingen kjøp for kontoen din. Hvis du tror dette er en feil, kan du kontakte kundestøtte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple har mottatt forespørselen din om refusjon."; +"customer_center_refund_error" = "Noe gikk galt under forespørselen om refusjon. Prøv igjen."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Oppdatering tilgjengelig"; +"customer_center_update_message" = "Å laste ned den nyeste versjonen av appen kan bidra til å løse problemet."; +"customer_center_update_action" = "Oppdater"; +"customer_center_update_continue" = "Fortsett"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Du har kanskje doble abonnementer"; +"customer_center_duplicate_message" = "Du er kanskje abonnent både på nettet og via App Store. For å unngå å bli belastet to ganger, kan du si opp ett av dem."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Support-forespørsel"; +"customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; +"customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "Det er ingenting å administrere for dette kjøpet."; +"customer_center_detail_managed_through" = "Administrer dette abonnementet via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnementet der du kjøpte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 848e8c1e20..bec4e5fc43 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gotowe"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Zarządzaj subskrypcją"; +"customer_center_no_purchases_title" = "Nie znaleziono subskrypcji"; +"customer_center_no_purchases_subtitle" = "Możemy sprawdzić poprzednie zakupy."; +"customer_center_close" = "Zamknij"; +"customer_center_done" = "Gotowe"; +"customer_center_cancel" = "Anuluj"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Przywróć zakupy"; +"customer_center_path_manage_subscription" = "Anuluj subskrypcję"; +"customer_center_path_manage_subscription_web" = "Zarządzaj subskrypcją"; +"customer_center_web_manage_unavailable" = "Zarządzaj subskrypcją przy użyciu linku w potwierdzeniu e-mail."; +"customer_center_path_refund" = "Poproś o zwrot pieniędzy"; +"customer_center_path_change_plan" = "Zmień plan"; +"customer_center_path_contact_support" = "Skontaktuj się z pomocą techniczną"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Dlaczego rezygnujesz?"; +"customer_center_survey_too_expensive" = "Zbyt drogie"; +"customer_center_survey_dont_use" = "Nie korzystam z aplikacji"; +"customer_center_survey_bought_by_mistake" = "Kupione przez pomyłkę"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Odnawia się %@ za %@"; +"customer_center_renews_on" = "Odnawia się %@"; +"customer_center_expires_on" = "Wygasa %@"; +"customer_center_expired_on" = "Wygasło %@"; +"customer_center_free_trial_until" = "Bezpłatny okres próbny do %@"; +"customer_center_billing_issue" = "Problem z płatnością – zaktualizuj metodę płatności, aby zachować dostęp"; +"customer_center_lifetime" = "Dostęp dożywotni"; +"customer_center_revoked" = "Zwrócono środki"; +"customer_center_purchased_on" = "Zakupiono %@"; +"customer_center_active_via_superwall" = "Aktywna"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Wygasła"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktywna"; +"customer_center_badge_free_trial" = "Bezpłatny okres próbny"; +"customer_center_badge_cancelled" = "Anulowana"; +"customer_center_badge_billing_issue" = "Problem z płatnością"; +"customer_center_badge_expired" = "Wygasła"; +"customer_center_badge_revoked" = "Zwrócono środki"; +"customer_center_badge_lifetime" = "Dożywotnia"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Internet"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Inne"; +"customer_center_family_shared" = "Udostępniono przez Rodzinę"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subskrypcje"; +"customer_center_section_purchases" = "Zakupy"; +"customer_center_section_actions" = "Działania"; +"customer_center_account_details" = "Szczegóły konta"; +"customer_center_user_id" = "ID użytkownika"; +"customer_center_copy" = "Kopiuj"; +"customer_center_copied" = "Skopiowano"; +"customer_center_original_download_date" = "Pierwotna data pobrania"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Przywracanie…"; +"customer_center_restore_success_title" = "Zakupy przywrócone"; +"customer_center_restore_success_message" = "Przywróciliśmy Twoje poprzednie zakupy i zastosowaliśmy je do Twojego konta."; +"customer_center_restore_none_title" = "Brak poprzednich zakupów"; +"customer_center_restore_none_message" = "Nie znaleźliśmy żadnych zakupów dla Twojego konta. Jeśli uważasz, że to błąd, skontaktuj się z pomocą techniczną."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple otrzymało Twoją prośbę o zwrot pieniędzy."; +"customer_center_refund_error" = "Coś poszło nie tak podczas składania prośby o zwrot pieniędzy. Spróbuj ponownie."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Dostępna aktualizacja"; +"customer_center_update_message" = "Pobranie najnowszej wersji aplikacji może pomóc rozwiązać ten problem."; +"customer_center_update_action" = "Aktualizuj"; +"customer_center_update_continue" = "Kontynuuj"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Możesz mieć zduplikowane subskrypcje"; +"customer_center_duplicate_message" = "Możesz być subskrybentem zarówno w internecie, jak i przez App Store. Aby uniknąć podwójnej opłaty, anuluj jedną z nich."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Zgłoszenie do pomocy technicznej"; +"customer_center_support_body" = "Opisz swój problem lub pytanie."; +"customer_center_no_mail_app" = "Na tym urządzeniu nie skonfigurowano aplikacji pocztowej. Możesz się z nami skontaktować pod adresem %@."; +"customer_center_detail_nothing_to_manage" = "Nie ma nic do zarządzania w tym zakupie."; +"customer_center_detail_managed_through" = "Zarządzaj tą subskrypcją przez %@."; +"customer_center_detail_managed_where_bought" = "Zarządzaj tą subskrypcją tam, gdzie ją kupiono."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index e5628fdcd1..357c70b2ca 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; +"customer_center_close" = "Fechar"; +"customer_center_done" = "Concluído"; +"customer_center_cancel" = "Cancelar"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurar compras"; +"customer_center_path_manage_subscription" = "Cancelar subscrição"; +"customer_center_path_manage_subscription_web" = "Gerir subscrição"; +"customer_center_web_manage_unavailable" = "Faça a gestão da sua subscrição através da ligação no recibo enviado por e-mail."; +"customer_center_path_refund" = "Pedir reembolso"; +"customer_center_path_change_plan" = "Alterar plano"; +"customer_center_path_contact_support" = "Contactar suporte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Porque está a cancelar?"; +"customer_center_survey_too_expensive" = "Demasiado caro"; +"customer_center_survey_dont_use" = "Não utilizo a aplicação"; +"customer_center_survey_bought_by_mistake" = "Comprado por engano"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renova a %@ por %@"; +"customer_center_renews_on" = "Renova a %@"; +"customer_center_expires_on" = "Expira a %@"; +"customer_center_expired_on" = "Expirou a %@"; +"customer_center_free_trial_until" = "Teste gratuito até %@"; +"customer_center_billing_issue" = "Problema de faturação – atualize o seu método de pagamento para manter o acesso"; +"customer_center_lifetime" = "Acesso vitalício"; +"customer_center_revoked" = "Reembolsado"; +"customer_center_purchased_on" = "Comprado a %@"; +"customer_center_active_via_superwall" = "Ativa"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expirada"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Ativa"; +"customer_center_badge_free_trial" = "Teste gratuito"; +"customer_center_badge_cancelled" = "Cancelada"; +"customer_center_badge_billing_issue" = "Problema de faturação"; +"customer_center_badge_expired" = "Expirada"; +"customer_center_badge_revoked" = "Reembolsado"; +"customer_center_badge_lifetime" = "Vitalícia"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Outro"; +"customer_center_family_shared" = "Partilhado através da Partilha Familiar"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscrições"; +"customer_center_section_purchases" = "Compras"; +"customer_center_section_actions" = "Ações"; +"customer_center_account_details" = "Detalhes da conta"; +"customer_center_user_id" = "ID de utilizador"; +"customer_center_copy" = "Copiar"; +"customer_center_copied" = "Copiado"; +"customer_center_original_download_date" = "Data de transferência original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "A restaurar…"; +"customer_center_restore_success_title" = "Compras restauradas"; +"customer_center_restore_success_message" = "Restaurámos as suas compras anteriores e aplicámo-las à sua conta."; +"customer_center_restore_none_title" = "Sem compras anteriores"; +"customer_center_restore_none_message" = "Não encontrámos quaisquer compras para a sua conta. Se acha que se trata de um erro, contacte o suporte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "A Apple recebeu o seu pedido de reembolso."; +"customer_center_refund_error" = "Ocorreu um erro ao pedir o reembolso. Tente novamente."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Atualização disponível"; +"customer_center_update_message" = "Transferir a versão mais recente da aplicação pode ajudar a resolver o problema."; +"customer_center_update_action" = "Atualizar"; +"customer_center_update_continue" = "Continuar"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Pode ter subscrições duplicadas"; +"customer_center_duplicate_message" = "Poderá estar subscrito tanto na web como através da App Store. Para evitar ser cobrado duas vezes, cancele uma delas."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Pedido de suporte"; +"customer_center_support_body" = "Descreva o seu problema ou questão."; +"customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerir nesta compra."; +"customer_center_detail_managed_through" = "Gira esta subscrição através de %@."; +"customer_center_detail_managed_where_bought" = "Gira esta subscrição onde a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index e366bcc29f..cdfd168977 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; +"customer_center_close" = "Fechar"; +"customer_center_done" = "Concluído"; +"customer_center_cancel" = "Cancelar"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurar compras"; +"customer_center_path_manage_subscription" = "Cancelar assinatura"; +"customer_center_path_manage_subscription_web" = "Gerenciar assinatura"; +"customer_center_web_manage_unavailable" = "Gerencie sua assinatura pelo link no recibo enviado por e-mail."; +"customer_center_path_refund" = "Pedir reembolso"; +"customer_center_path_change_plan" = "Alterar plano"; +"customer_center_path_contact_support" = "Contactar suporte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Porque está a cancelar?"; +"customer_center_survey_too_expensive" = "Demasiado caro"; +"customer_center_survey_dont_use" = "Não utilizo a aplicação"; +"customer_center_survey_bought_by_mistake" = "Comprado por engano"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renova a %@ por %@"; +"customer_center_renews_on" = "Renova a %@"; +"customer_center_expires_on" = "Expira a %@"; +"customer_center_expired_on" = "Expirou a %@"; +"customer_center_free_trial_until" = "Teste gratuito até %@"; +"customer_center_billing_issue" = "Problema de faturação – atualize o seu método de pagamento para manter o acesso"; +"customer_center_lifetime" = "Acesso vitalício"; +"customer_center_revoked" = "Reembolsado"; +"customer_center_purchased_on" = "Comprado a %@"; +"customer_center_active_via_superwall" = "Ativa"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expirada"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Ativa"; +"customer_center_badge_free_trial" = "Teste gratuito"; +"customer_center_badge_cancelled" = "Cancelada"; +"customer_center_badge_billing_issue" = "Problema de faturação"; +"customer_center_badge_expired" = "Expirada"; +"customer_center_badge_revoked" = "Reembolsado"; +"customer_center_badge_lifetime" = "Vitalícia"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Outro"; +"customer_center_family_shared" = "Partilhado através da Partilha Familiar"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscrições"; +"customer_center_section_purchases" = "Compras"; +"customer_center_section_actions" = "Ações"; +"customer_center_account_details" = "Detalhes da conta"; +"customer_center_user_id" = "ID de utilizador"; +"customer_center_copy" = "Copiar"; +"customer_center_copied" = "Copiado"; +"customer_center_original_download_date" = "Data de transferência original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "A restaurar…"; +"customer_center_restore_success_title" = "Compras restauradas"; +"customer_center_restore_success_message" = "Restaurámos as suas compras anteriores e aplicámo-las à sua conta."; +"customer_center_restore_none_title" = "Sem compras anteriores"; +"customer_center_restore_none_message" = "Não encontrámos quaisquer compras para a sua conta. Se acha que se trata de um erro, contacte o suporte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "A Apple recebeu o seu pedido de reembolso."; +"customer_center_refund_error" = "Ocorreu um erro ao pedir o reembolso. Tente novamente."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Atualização disponível"; +"customer_center_update_message" = "Transferir a versão mais recente da aplicação pode ajudar a resolver o problema."; +"customer_center_update_action" = "Atualizar"; +"customer_center_update_continue" = "Continuar"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Pode ter subscrições duplicadas"; +"customer_center_duplicate_message" = "Poderá estar subscrito tanto na web como através da App Store. Para evitar ser cobrado duas vezes, cancele uma delas."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Pedido de suporte"; +"customer_center_support_body" = "Descreva o seu problema ou questão."; +"customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerenciar nesta compra."; +"customer_center_detail_managed_through" = "Gerencie esta assinatura pelo %@."; +"customer_center_detail_managed_where_bought" = "Gerencie esta assinatura onde você a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index c522b2928d..61ceba3aab 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; +"customer_center_close" = "Fechar"; +"customer_center_done" = "Concluído"; +"customer_center_cancel" = "Cancelar"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurar compras"; +"customer_center_path_manage_subscription" = "Cancelar subscrição"; +"customer_center_path_manage_subscription_web" = "Gerir subscrição"; +"customer_center_web_manage_unavailable" = "Faça a gestão da sua subscrição através da ligação no recibo enviado por e-mail."; +"customer_center_path_refund" = "Pedir reembolso"; +"customer_center_path_change_plan" = "Alterar plano"; +"customer_center_path_contact_support" = "Contactar suporte"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Porque está a cancelar?"; +"customer_center_survey_too_expensive" = "Demasiado caro"; +"customer_center_survey_dont_use" = "Não utilizo a aplicação"; +"customer_center_survey_bought_by_mistake" = "Comprado por engano"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Renova a %@ por %@"; +"customer_center_renews_on" = "Renova a %@"; +"customer_center_expires_on" = "Expira a %@"; +"customer_center_expired_on" = "Expirou a %@"; +"customer_center_free_trial_until" = "Teste gratuito até %@"; +"customer_center_billing_issue" = "Problema de faturação – atualize o seu método de pagamento para manter o acesso"; +"customer_center_lifetime" = "Acesso vitalício"; +"customer_center_revoked" = "Reembolsado"; +"customer_center_purchased_on" = "Comprado a %@"; +"customer_center_active_via_superwall" = "Ativa"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expirada"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Ativa"; +"customer_center_badge_free_trial" = "Teste gratuito"; +"customer_center_badge_cancelled" = "Cancelada"; +"customer_center_badge_billing_issue" = "Problema de faturação"; +"customer_center_badge_expired" = "Expirada"; +"customer_center_badge_revoked" = "Reembolsado"; +"customer_center_badge_lifetime" = "Vitalícia"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Outro"; +"customer_center_family_shared" = "Partilhado através da Partilha Familiar"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Subscrições"; +"customer_center_section_purchases" = "Compras"; +"customer_center_section_actions" = "Ações"; +"customer_center_account_details" = "Detalhes da conta"; +"customer_center_user_id" = "ID de utilizador"; +"customer_center_copy" = "Copiar"; +"customer_center_copied" = "Copiado"; +"customer_center_original_download_date" = "Data de transferência original"; + +/* Customer Center – restore */ +"customer_center_restoring" = "A restaurar…"; +"customer_center_restore_success_title" = "Compras restauradas"; +"customer_center_restore_success_message" = "Restaurámos as suas compras anteriores e aplicámo-las à sua conta."; +"customer_center_restore_none_title" = "Sem compras anteriores"; +"customer_center_restore_none_message" = "Não encontrámos quaisquer compras para a sua conta. Se acha que se trata de um erro, contacte o suporte."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "A Apple recebeu o seu pedido de reembolso."; +"customer_center_refund_error" = "Ocorreu um erro ao pedir o reembolso. Tente novamente."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Atualização disponível"; +"customer_center_update_message" = "Transferir a versão mais recente da aplicação pode ajudar a resolver o problema."; +"customer_center_update_action" = "Atualizar"; +"customer_center_update_continue" = "Continuar"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Pode ter subscrições duplicadas"; +"customer_center_duplicate_message" = "Poderá estar subscrito tanto na web como através da App Store. Para evitar ser cobrado duas vezes, cancele uma delas."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Pedido de suporte"; +"customer_center_support_body" = "Descreva o seu problema ou questão."; +"customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerir nesta compra."; +"customer_center_detail_managed_through" = "Gira esta subscrição através de %@."; +"customer_center_detail_managed_where_bought" = "Gira esta subscrição onde a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index bc4ed716ce..2894a81ee2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gata"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestionați-vă abonamentul"; +"customer_center_no_purchases_title" = "Nu s-a găsit niciun abonament"; +"customer_center_no_purchases_subtitle" = "Putem verifica achizițiile anterioare."; +"customer_center_close" = "Închide"; +"customer_center_done" = "Terminat"; +"customer_center_cancel" = "Anulează"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Restaurați achizițiile"; +"customer_center_path_manage_subscription" = "Anulați abonamentul"; +"customer_center_path_manage_subscription_web" = "Gestionează abonamentul"; +"customer_center_web_manage_unavailable" = "Gestionează-ți abonamentul folosind linkul din chitanța primită prin e-mail."; +"customer_center_path_refund" = "Solicitați o rambursare"; +"customer_center_path_change_plan" = "Schimbați planul"; +"customer_center_path_contact_support" = "Contactați asistența"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "De ce anulați?"; +"customer_center_survey_too_expensive" = "Prea scump"; +"customer_center_survey_dont_use" = "Nu folosesc aplicația"; +"customer_center_survey_bought_by_mistake" = "Cumpărat din greșeală"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Se reînnoiește pe %@ pentru %@"; +"customer_center_renews_on" = "Se reînnoiește pe %@"; +"customer_center_expires_on" = "Expiră pe %@"; +"customer_center_expired_on" = "A expirat pe %@"; +"customer_center_free_trial_until" = "Perioadă de probă gratuită până pe %@"; +"customer_center_billing_issue" = "Problemă de facturare – actualizați metoda de plată pentru a vă păstra accesul"; +"customer_center_lifetime" = "Acces pe viață"; +"customer_center_revoked" = "Rambursat"; +"customer_center_purchased_on" = "Cumpărat pe %@"; +"customer_center_active_via_superwall" = "Activ"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Expirat"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Activ"; +"customer_center_badge_free_trial" = "Perioadă de probă gratuită"; +"customer_center_badge_cancelled" = "Anulat"; +"customer_center_badge_billing_issue" = "Problemă de facturare"; +"customer_center_badge_expired" = "Expirat"; +"customer_center_badge_revoked" = "Rambursat"; +"customer_center_badge_lifetime" = "Pe viață"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Altul"; +"customer_center_family_shared" = "Partajat prin Partajare familială"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonamente"; +"customer_center_section_purchases" = "Achiziții"; +"customer_center_section_actions" = "Acțiuni"; +"customer_center_account_details" = "Detaliile contului"; +"customer_center_user_id" = "ID utilizator"; +"customer_center_copy" = "Copiază"; +"customer_center_copied" = "Copiat"; +"customer_center_original_download_date" = "Data descărcării inițiale"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Se restaurează…"; +"customer_center_restore_success_title" = "Achiziții restaurate"; +"customer_center_restore_success_message" = "Am restaurat achizițiile dvs. anterioare și le-am aplicat contului dvs."; +"customer_center_restore_none_title" = "Nicio achiziție anterioară"; +"customer_center_restore_none_message" = "Nu am găsit nicio achiziție pentru contul dvs. Dacă credeți că este o eroare, contactați asistența."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple a primit cererea dvs. de rambursare."; +"customer_center_refund_error" = "Ceva nu a funcționat la solicitarea rambursării. Vă rugăm să încercați din nou."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Actualizare disponibilă"; +"customer_center_update_message" = "Descărcarea celei mai recente versiuni a aplicației ar putea ajuta la rezolvarea problemei."; +"customer_center_update_action" = "Actualizează"; +"customer_center_update_continue" = "Continuă"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Este posibil să aveți abonamente duplicate"; +"customer_center_duplicate_message" = "Este posibil să fiți abonat atât pe web, cât și prin App Store. Pentru a evita o taxare dublă, anulați unul dintre ele."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Solicitare de asistență"; +"customer_center_support_body" = "Vă rugăm să descrieți problema sau întrebarea dvs."; +"customer_center_no_mail_app" = "Nu este configurată nicio aplicație de e-mail pe acest dispozitiv. Ne puteți contacta la %@."; +"customer_center_detail_nothing_to_manage" = "Nu există nimic de gestionat pentru această achiziție."; +"customer_center_detail_managed_through" = "Gestionează acest abonament prin %@."; +"customer_center_detail_managed_where_bought" = "Gestionează acest abonament acolo unde l-ai cumpărat."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index db11b63943..74c378c188 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Готово"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Управление подпиской"; +"customer_center_no_purchases_title" = "Подписки не найдены"; +"customer_center_no_purchases_subtitle" = "Мы можем проверить наличие предыдущих покупок."; +"customer_center_close" = "Закрыть"; +"customer_center_done" = "Готово"; +"customer_center_cancel" = "Отмена"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Восстановить покупки"; +"customer_center_path_manage_subscription" = "Отмена подписки"; +"customer_center_path_manage_subscription_web" = "Управление подпиской"; +"customer_center_web_manage_unavailable" = "Управляйте подпиской по ссылке из чека, отправленного на почту."; +"customer_center_path_refund" = "Запросить возврат средств"; +"customer_center_path_change_plan" = "Изменить план"; +"customer_center_path_contact_support" = "Связаться со службой поддержки"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Почему вы отменяете подписку?"; +"customer_center_survey_too_expensive" = "Слишком дорого"; +"customer_center_survey_dont_use" = "Не пользуюсь приложением"; +"customer_center_survey_bought_by_mistake" = "Куплено по ошибке"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Продлевается %@ за %@"; +"customer_center_renews_on" = "Продлевается %@"; +"customer_center_expires_on" = "Истекает %@"; +"customer_center_expired_on" = "Истекло %@"; +"customer_center_free_trial_until" = "Бесплатный пробный период до %@"; +"customer_center_billing_issue" = "Проблема с оплатой – обновите способ оплаты, чтобы сохранить доступ"; +"customer_center_lifetime" = "Пожизненный доступ"; +"customer_center_revoked" = "Возвращены средства"; +"customer_center_purchased_on" = "Куплено %@"; +"customer_center_active_via_superwall" = "Активна"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Истекла"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Активна"; +"customer_center_badge_free_trial" = "Бесплатный пробный период"; +"customer_center_badge_cancelled" = "Отменена"; +"customer_center_badge_billing_issue" = "Проблема с оплатой"; +"customer_center_badge_expired" = "Истекла"; +"customer_center_badge_revoked" = "Возвращены средства"; +"customer_center_badge_lifetime" = "Пожизненная"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Веб"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Другое"; +"customer_center_family_shared" = "Предоставлено через семейный доступ"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Подписки"; +"customer_center_section_purchases" = "Покупки"; +"customer_center_section_actions" = "Действия"; +"customer_center_account_details" = "Данные аккаунта"; +"customer_center_user_id" = "ID пользователя"; +"customer_center_copy" = "Копировать"; +"customer_center_copied" = "Скопировано"; +"customer_center_original_download_date" = "Дата первой загрузки"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Восстановление…"; +"customer_center_restore_success_title" = "Покупки восстановлены"; +"customer_center_restore_success_message" = "Мы восстановили ваши предыдущие покупки и применили их к вашему аккаунту."; +"customer_center_restore_none_title" = "Нет предыдущих покупок"; +"customer_center_restore_none_message" = "Мы не смогли найти покупки для вашего аккаунта. Если вы считаете, что это ошибка, обратитесь в службу поддержки."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple получила ваш запрос на возврат средств."; +"customer_center_refund_error" = "Что-то пошло не так при запросе возврата средств. Попробуйте ещё раз."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Доступно обновление"; +"customer_center_update_message" = "Загрузка последней версии приложения может помочь решить проблему."; +"customer_center_update_action" = "Обновить"; +"customer_center_update_continue" = "Продолжить"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "У вас могут быть дублирующиеся подписки"; +"customer_center_duplicate_message" = "Возможно, вы подписаны как через веб, так и через App Store. Чтобы избежать двойного списания средств, отмените одну из подписок."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Запрос в поддержку"; +"customer_center_support_body" = "Пожалуйста, опишите вашу проблему или вопрос."; +"customer_center_no_mail_app" = "На этом устройстве не настроено почтовое приложение. Вы можете связаться с нами по адресу %@."; +"customer_center_detail_nothing_to_manage" = "Для этой покупки нечего настраивать."; +"customer_center_detail_managed_through" = "Управляйте этой подпиской через %@."; +"customer_center_detail_managed_where_bought" = "Управляйте этой подпиской там, где вы её оформили."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index ed6ad5a6da..b6632d2167 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Hotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Spravovať predplatné"; +"customer_center_no_purchases_title" = "Nenašlo sa žiadne predplatné"; +"customer_center_no_purchases_subtitle" = "Môžeme skontrolovať predchádzajúce nákupy."; +"customer_center_close" = "Zavrieť"; +"customer_center_done" = "Hotovo"; +"customer_center_cancel" = "Zrušiť"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Obnoviť nákupy"; +"customer_center_path_manage_subscription" = "Zrušiť predplatné"; +"customer_center_path_manage_subscription_web" = "Spravovať predplatné"; +"customer_center_web_manage_unavailable" = "Spravujte predplatné pomocou odkazu v e-mailovej účtenke."; +"customer_center_path_refund" = "Požiadať o vrátenie peňazí"; +"customer_center_path_change_plan" = "Zmeniť plán"; +"customer_center_path_contact_support" = "Kontaktovať podporu"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Prečo rušíte predplatné?"; +"customer_center_survey_too_expensive" = "Príliš drahé"; +"customer_center_survey_dont_use" = "Aplikáciu nepoužívam"; +"customer_center_survey_bought_by_mistake" = "Kúpené omylom"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Obnoví sa %@ za %@"; +"customer_center_renews_on" = "Obnoví sa %@"; +"customer_center_expires_on" = "Vyprší %@"; +"customer_center_expired_on" = "Vypršalo %@"; +"customer_center_free_trial_until" = "Bezplatná skúšobná verzia do %@"; +"customer_center_billing_issue" = "Problém s platbou – aktualizujte spôsob platby, aby ste si zachovali prístup"; +"customer_center_lifetime" = "Doživotný prístup"; +"customer_center_revoked" = "Vrátené"; +"customer_center_purchased_on" = "Zakúpené %@"; +"customer_center_active_via_superwall" = "Aktívne"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Vypršalo"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktívne"; +"customer_center_badge_free_trial" = "Bezplatná skúšobná verzia"; +"customer_center_badge_cancelled" = "Zrušené"; +"customer_center_badge_billing_issue" = "Problém s platbou"; +"customer_center_badge_expired" = "Vypršalo"; +"customer_center_badge_revoked" = "Vrátené"; +"customer_center_badge_lifetime" = "Doživotné"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Iné"; +"customer_center_family_shared" = "Zdieľané prostredníctvom rodinného zdieľania"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Predplatné"; +"customer_center_section_purchases" = "Nákupy"; +"customer_center_section_actions" = "Akcie"; +"customer_center_account_details" = "Podrobnosti o účte"; +"customer_center_user_id" = "ID používateľa"; +"customer_center_copy" = "Kopírovať"; +"customer_center_copied" = "Skopírované"; +"customer_center_original_download_date" = "Dátum pôvodného stiahnutia"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Obnovuje sa…"; +"customer_center_restore_success_title" = "Nákupy obnovené"; +"customer_center_restore_success_message" = "Obnovili sme vaše predchádzajúce nákupy a priradili sme ich k vášmu účtu."; +"customer_center_restore_none_title" = "Žiadne predchádzajúce nákupy"; +"customer_center_restore_none_message" = "Pre váš účet sme nenašli žiadne nákupy. Ak si myslíte, že ide o chybu, kontaktujte podporu."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple prijalo vašu žiadosť o vrátenie peňazí."; +"customer_center_refund_error" = "Pri žiadosti o vrátenie peňazí sa niečo pokazilo. Skúste to znova."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "K dispozícii je aktualizácia"; +"customer_center_update_message" = "Stiahnutie najnovšej verzie aplikácie môže pomôcť vyriešiť problém."; +"customer_center_update_action" = "Aktualizovať"; +"customer_center_update_continue" = "Pokračovať"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Možno máte duplicitné predplatné"; +"customer_center_duplicate_message" = "Je možné, že ste predplatiteľom na webe aj cez App Store zároveň. Aby ste sa vyhli dvojitému účtovaniu, jedno z nich zrušte."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Žiadosť o podporu"; +"customer_center_support_body" = "Opíšte, prosím, váš problém alebo otázku."; +"customer_center_no_mail_app" = "V tomto zariadení nie je nastavená žiadna e-mailová aplikácia. Môžete nás kontaktovať na %@."; +"customer_center_detail_nothing_to_manage" = "Pri tomto nákupe nie je čo spravovať."; +"customer_center_detail_managed_through" = "Toto predplatné spravujte cez %@."; +"customer_center_detail_managed_where_bought" = "Toto predplatné spravujte tam, kde ste ho kúpili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 020b6e1e8a..8dfe573c91 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Končano"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Upravljanje naročnine"; +"customer_center_no_purchases_title" = "Ni najdenih naročnin"; +"customer_center_no_purchases_subtitle" = "Preverimo lahko prejšnje nakupe."; +"customer_center_close" = "Zapri"; +"customer_center_done" = "Končano"; +"customer_center_cancel" = "Prekliči"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Obnovi nakupe"; +"customer_center_path_manage_subscription" = "Preklic naročnine"; +"customer_center_path_manage_subscription_web" = "Upravljanje naročnine"; +"customer_center_web_manage_unavailable" = "Naročnino upravljajte prek povezave v računu, poslanem po e-pošti."; +"customer_center_path_refund" = "Zahtevaj vračilo denarja"; +"customer_center_path_change_plan" = "Spremeni paket"; +"customer_center_path_contact_support" = "Obrni se na podporo"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Zakaj preklicujete?"; +"customer_center_survey_too_expensive" = "Predrago"; +"customer_center_survey_dont_use" = "Aplikacije ne uporabljam"; +"customer_center_survey_bought_by_mistake" = "Kupljeno pomotoma"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Obnovi se %@ za %@"; +"customer_center_renews_on" = "Obnovi se %@"; +"customer_center_expires_on" = "Poteče %@"; +"customer_center_expired_on" = "Poteklo %@"; +"customer_center_free_trial_until" = "Brezplačna preizkusna doba do %@"; +"customer_center_billing_issue" = "Težava z zaračunavanjem – posodobite način plačila, da ohranite dostop"; +"customer_center_lifetime" = "Dostop za vse življenje"; +"customer_center_revoked" = "Vrnjeno"; +"customer_center_purchased_on" = "Kupljeno %@"; +"customer_center_active_via_superwall" = "Aktivna"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Poteklo"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktivna"; +"customer_center_badge_free_trial" = "Brezplačna preizkusna doba"; +"customer_center_badge_cancelled" = "Preklicano"; +"customer_center_badge_billing_issue" = "Težava z zaračunavanjem"; +"customer_center_badge_expired" = "Poteklo"; +"customer_center_badge_revoked" = "Vrnjeno"; +"customer_center_badge_lifetime" = "Vseživljenjsko"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Splet"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Drugo"; +"customer_center_family_shared" = "V skupni rabi prek Družinske skupne rabe"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Naročnine"; +"customer_center_section_purchases" = "Nakupi"; +"customer_center_section_actions" = "Dejanja"; +"customer_center_account_details" = "Podrobnosti računa"; +"customer_center_user_id" = "ID uporabnika"; +"customer_center_copy" = "Kopiraj"; +"customer_center_copied" = "Kopirano"; +"customer_center_original_download_date" = "Datum prvotnega prenosa"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Obnavljanje…"; +"customer_center_restore_success_title" = "Nakupi obnovljeni"; +"customer_center_restore_success_message" = "Obnovili smo vaše prejšnje nakupe in jih uveljavili na vašem računu."; +"customer_center_restore_none_title" = "Ni prejšnjih nakupov"; +"customer_center_restore_none_message" = "Za vaš račun nismo našli nobenih nakupov. Če menite, da gre za napako, se obrnite na podporo."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple je prejel vaš zahtevek za vračilo denarja."; +"customer_center_refund_error" = "Pri zahtevi za vračilo denarja je prišlo do napake. Poskusite znova."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Na voljo je posodobitev"; +"customer_center_update_message" = "Prenos najnovejše različice aplikacije lahko pomaga rešiti težavo."; +"customer_center_update_action" = "Posodobi"; +"customer_center_update_continue" = "Nadaljuj"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Morda imate podvojene naročnine"; +"customer_center_duplicate_message" = "Mogoče ste naročeni tako prek spleta kot prek App Store. Da se izognete dvojnemu zaračunavanju, eno od njiju prekličite."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Zahteva za podporo"; +"customer_center_support_body" = "Opišite svojo težavo ali vprašanje."; +"customer_center_no_mail_app" = "V tej napravi ni nastavljena aplikacija za e-pošto. Lahko nas kontaktirate na %@."; +"customer_center_detail_nothing_to_manage" = "Za ta nakup ni ničesar za upravljanje."; +"customer_center_detail_managed_through" = "To naročnino upravljajte prek %@."; +"customer_center_detail_managed_where_bought" = "To naročnino upravljajte tam, kjer ste jo kupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index a5ed2ada4f..e6788e6258 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Klar"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Hantera din prenumeration"; +"customer_center_no_purchases_title" = "Inga prenumerationer hittades"; +"customer_center_no_purchases_subtitle" = "Vi kan kontrollera om det finns tidigare köp."; +"customer_center_close" = "Stäng"; +"customer_center_done" = "Klar"; +"customer_center_cancel" = "Avbryt"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Återställ köp"; +"customer_center_path_manage_subscription" = "Avsluta prenumeration"; +"customer_center_path_manage_subscription_web" = "Hantera prenumeration"; +"customer_center_web_manage_unavailable" = "Hantera din prenumeration via länken i ditt kvitto via e-post."; +"customer_center_path_refund" = "Begär återbetalning"; +"customer_center_path_change_plan" = "Byt plan"; +"customer_center_path_contact_support" = "Kontakta support"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Varför säger du upp?"; +"customer_center_survey_too_expensive" = "För dyrt"; +"customer_center_survey_dont_use" = "Använder inte appen"; +"customer_center_survey_bought_by_mistake" = "Köpt av misstag"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Förnyas %@ för %@"; +"customer_center_renews_on" = "Förnyas %@"; +"customer_center_expires_on" = "Upphör %@"; +"customer_center_expired_on" = "Upphörde %@"; +"customer_center_free_trial_until" = "Kostnadsfri provperiod till %@"; +"customer_center_billing_issue" = "Faktureringsproblem – uppdatera din betalningsmetod för att behålla åtkomsten"; +"customer_center_lifetime" = "Livstidsåtkomst"; +"customer_center_revoked" = "Återbetald"; +"customer_center_purchased_on" = "Köpt %@"; +"customer_center_active_via_superwall" = "Aktiv"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Upphörd"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktiv"; +"customer_center_badge_free_trial" = "Kostnadsfri provperiod"; +"customer_center_badge_cancelled" = "Uppsagd"; +"customer_center_badge_billing_issue" = "Faktureringsproblem"; +"customer_center_badge_expired" = "Upphörd"; +"customer_center_badge_revoked" = "Återbetald"; +"customer_center_badge_lifetime" = "Livstid"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Webb"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Annat"; +"customer_center_family_shared" = "Delas via Familjedelning"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Prenumerationer"; +"customer_center_section_purchases" = "Köp"; +"customer_center_section_actions" = "Åtgärder"; +"customer_center_account_details" = "Kontouppgifter"; +"customer_center_user_id" = "Användar-ID"; +"customer_center_copy" = "Kopiera"; +"customer_center_copied" = "Kopierat"; +"customer_center_original_download_date" = "Ursprungligt nedladdningsdatum"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Återställer…"; +"customer_center_restore_success_title" = "Köp återställda"; +"customer_center_restore_success_message" = "Vi har återställt dina tidigare köp och tillämpat dem på ditt konto."; +"customer_center_restore_none_title" = "Inga tidigare köp"; +"customer_center_restore_none_message" = "Vi kunde inte hitta några köp för ditt konto. Om du tror att detta är ett fel, kontakta supporten."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple har tagit emot din begäran om återbetalning."; +"customer_center_refund_error" = "Något gick fel när återbetalningen begärdes. Försök igen."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Uppdatering tillgänglig"; +"customer_center_update_message" = "Att ladda ner den senaste versionen av appen kan hjälpa till att lösa problemet."; +"customer_center_update_action" = "Uppdatera"; +"customer_center_update_continue" = "Fortsätt"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Du kan ha dubbla prenumerationer"; +"customer_center_duplicate_message" = "Du kan vara prenumerant både på webben och via App Store. För att undvika att debiteras dubbelt bör du säga upp en av dem."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Supportförfrågan"; +"customer_center_support_body" = "Beskriv ditt problem eller din fråga."; +"customer_center_no_mail_app" = "Ingen e-postapp är konfigurerad på den här enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "Det finns inget att hantera för det här köpet."; +"customer_center_detail_managed_through" = "Hantera den här prenumerationen via %@."; +"customer_center_detail_managed_where_bought" = "Hantera den här prenumerationen där du köpte den."; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index e250920dc6..c95da90919 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "เสร็จสิ้น"; + +/* Customer Center – screens */ +"customer_center_management_title" = "จัดการการสมัครสมาชิกของคุณ"; +"customer_center_no_purchases_title" = "ไม่พบการสมัครสมาชิก"; +"customer_center_no_purchases_subtitle" = "เราสามารถตรวจสอบการซื้อก่อนหน้านี้ได้"; +"customer_center_close" = "ปิด"; +"customer_center_done" = "เสร็จสิ้น"; +"customer_center_cancel" = "ยกเลิก"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "กู้คืนการซื้อ"; +"customer_center_path_manage_subscription" = "ยกเลิกการสมัครสมาชิก"; +"customer_center_path_manage_subscription_web" = "จัดการการสมัครสมาชิก"; +"customer_center_web_manage_unavailable" = "จัดการการสมัครสมาชิกของคุณผ่านลิงก์ในใบเสร็จทางอีเมล"; +"customer_center_path_refund" = "ขอคืนเงิน"; +"customer_center_path_change_plan" = "เปลี่ยนแผน"; +"customer_center_path_contact_support" = "ติดต่อฝ่ายสนับสนุน"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "เหตุใดคุณจึงยกเลิก"; +"customer_center_survey_too_expensive" = "แพงเกินไป"; +"customer_center_survey_dont_use" = "ไม่ได้ใช้แอป"; +"customer_center_survey_bought_by_mistake" = "ซื้อโดยไม่ได้ตั้งใจ"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "ต่ออายุวันที่ %@ ในราคา %@"; +"customer_center_renews_on" = "ต่ออายุวันที่ %@"; +"customer_center_expires_on" = "หมดอายุวันที่ %@"; +"customer_center_expired_on" = "หมดอายุเมื่อวันที่ %@"; +"customer_center_free_trial_until" = "ทดลองใช้ฟรีจนถึงวันที่ %@"; +"customer_center_billing_issue" = "ปัญหาการเรียกเก็บเงิน – อัปเดตวิธีการชำระเงินของคุณเพื่อคงสิทธิ์การเข้าถึง"; +"customer_center_lifetime" = "สิทธิ์การเข้าถึงตลอดชีพ"; +"customer_center_revoked" = "คืนเงินแล้ว"; +"customer_center_purchased_on" = "ซื้อเมื่อวันที่ %@"; +"customer_center_active_via_superwall" = "ใช้งานอยู่"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "หมดอายุแล้ว"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "ใช้งานอยู่"; +"customer_center_badge_free_trial" = "ทดลองใช้ฟรี"; +"customer_center_badge_cancelled" = "ยกเลิกแล้ว"; +"customer_center_badge_billing_issue" = "ปัญหาการเรียกเก็บเงิน"; +"customer_center_badge_expired" = "หมดอายุแล้ว"; +"customer_center_badge_revoked" = "คืนเงินแล้ว"; +"customer_center_badge_lifetime" = "ตลอดชีพ"; + +/* Customer Center – stores */ +"customer_center_store_web" = "เว็บ"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "อื่นๆ"; +"customer_center_family_shared" = "แชร์ผ่านการแชร์กับครอบครัว"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "การสมัครสมาชิก"; +"customer_center_section_purchases" = "การซื้อ"; +"customer_center_section_actions" = "การดำเนินการ"; +"customer_center_account_details" = "รายละเอียดบัญชี"; +"customer_center_user_id" = "รหัสผู้ใช้"; +"customer_center_copy" = "คัดลอก"; +"customer_center_copied" = "คัดลอกแล้ว"; +"customer_center_original_download_date" = "วันที่ดาวน์โหลดครั้งแรก"; + +/* Customer Center – restore */ +"customer_center_restoring" = "กำลังกู้คืน…"; +"customer_center_restore_success_title" = "กู้คืนการซื้อแล้ว"; +"customer_center_restore_success_message" = "เราได้กู้คืนการซื้อก่อนหน้านี้ของคุณและนำไปใช้กับบัญชีของคุณแล้ว"; +"customer_center_restore_none_title" = "ไม่มีการซื้อก่อนหน้านี้"; +"customer_center_restore_none_message" = "เราไม่พบการซื้อใดๆ สำหรับบัญชีของคุณ หากคุณคิดว่านี่เป็นข้อผิดพลาด โปรดติดต่อฝ่ายสนับสนุน"; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple ได้รับคำขอคืนเงินของคุณแล้ว"; +"customer_center_refund_error" = "เกิดข้อผิดพลาดขณะขอคืนเงิน โปรดลองอีกครั้ง"; + +/* Customer Center – update warning */ +"customer_center_update_title" = "มีการอัปเดตพร้อมใช้งาน"; +"customer_center_update_message" = "การดาวน์โหลดแอปเวอร์ชันล่าสุดอาจช่วยแก้ไขปัญหาได้"; +"customer_center_update_action" = "อัปเดต"; +"customer_center_update_continue" = "ดำเนินการต่อ"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "คุณอาจมีการสมัครสมาชิกซ้ำซ้อน"; +"customer_center_duplicate_message" = "คุณอาจสมัครสมาชิกทั้งทางเว็บและผ่าน App Store เพื่อหลีกเลี่ยงการถูกเรียกเก็บเงินสองครั้ง โปรดยกเลิกรายการใดรายการหนึ่ง"; + +/* Customer Center – support */ +"customer_center_support_subject" = "คำขอการสนับสนุน"; +"customer_center_support_body" = "โปรดอธิบายปัญหาหรือคำถามของคุณ"; +"customer_center_no_mail_app" = "ไม่มีแอปอีเมลที่ตั้งค่าไว้บนอุปกรณ์นี้ คุณสามารถติดต่อเราได้ที่ %@"; +"customer_center_detail_nothing_to_manage" = "ไม่มีสิ่งที่ต้องจัดการสำหรับการซื้อนี้"; +"customer_center_detail_managed_through" = "จัดการการสมัครสมาชิกนี้ผ่าน %@"; +"customer_center_detail_managed_where_bought" = "จัดการการสมัครสมาชิกนี้จากที่ที่คุณซื้อ"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 39ede98efa..c028470270 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Bitti"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Aboneliğinizi yönetin"; +"customer_center_no_purchases_title" = "Abonelik bulunamadı"; +"customer_center_no_purchases_subtitle" = "Önceki satın alımlarınızı kontrol edebiliriz."; +"customer_center_close" = "Kapat"; +"customer_center_done" = "Bitti"; +"customer_center_cancel" = "İptal"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Satın alımları geri yükle"; +"customer_center_path_manage_subscription" = "Aboneliği iptal et"; +"customer_center_path_manage_subscription_web" = "Aboneliği yönet"; +"customer_center_web_manage_unavailable" = "Aboneliğinizi e-posta makbuzunuzdaki bağlantıyı kullanarak yönetin."; +"customer_center_path_refund" = "İade talep et"; +"customer_center_path_change_plan" = "Planı değiştir"; +"customer_center_path_contact_support" = "Destek ile iletişime geç"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Neden iptal ediyorsunuz?"; +"customer_center_survey_too_expensive" = "Çok pahalı"; +"customer_center_survey_dont_use" = "Uygulamayı kullanmıyorum"; +"customer_center_survey_bought_by_mistake" = "Yanlışlıkla satın alındı"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "%@ tarihinde %@ karşılığında yenilenir"; +"customer_center_renews_on" = "%@ tarihinde yenilenir"; +"customer_center_expires_on" = "%@ tarihinde sona erer"; +"customer_center_expired_on" = "%@ tarihinde sona erdi"; +"customer_center_free_trial_until" = "%@ tarihine kadar ücretsiz deneme"; +"customer_center_billing_issue" = "Faturalandırma sorunu – erişiminizi sürdürmek için ödeme yönteminizi güncelleyin"; +"customer_center_lifetime" = "Ömür boyu erişim"; +"customer_center_revoked" = "Para iadesi yapıldı"; +"customer_center_purchased_on" = "%@ tarihinde satın alındı"; +"customer_center_active_via_superwall" = "Aktif"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Süresi doldu"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Aktif"; +"customer_center_badge_free_trial" = "Ücretsiz deneme"; +"customer_center_badge_cancelled" = "İptal edildi"; +"customer_center_badge_billing_issue" = "Faturalandırma sorunu"; +"customer_center_badge_expired" = "Süresi doldu"; +"customer_center_badge_revoked" = "Para iadesi yapıldı"; +"customer_center_badge_lifetime" = "Ömür boyu"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Diğer"; +"customer_center_family_shared" = "Aile Paylaşımı ile paylaşıldı"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Abonelikler"; +"customer_center_section_purchases" = "Satın alımlar"; +"customer_center_section_actions" = "İşlemler"; +"customer_center_account_details" = "Hesap bilgileri"; +"customer_center_user_id" = "Kullanıcı kimliği"; +"customer_center_copy" = "Kopyala"; +"customer_center_copied" = "Kopyalandı"; +"customer_center_original_download_date" = "Orijinal indirme tarihi"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Geri yükleniyor…"; +"customer_center_restore_success_title" = "Satın alımlar geri yüklendi"; +"customer_center_restore_success_message" = "Geçmiş satın alımlarınızı geri yükledik ve hesabınıza uyguladık."; +"customer_center_restore_none_title" = "Geçmiş satın alım yok"; +"customer_center_restore_none_message" = "Hesabınız için herhangi bir satın alım bulamadık. Bunun bir hata olduğunu düşünüyorsanız lütfen destek ile iletişime geçin."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple, iade talebinizi aldı."; +"customer_center_refund_error" = "İade talep edilirken bir sorun oluştu. Lütfen tekrar deneyin."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Güncelleme mevcut"; +"customer_center_update_message" = "Uygulamanın en son sürümünü indirmek sorunu çözmeye yardımcı olabilir."; +"customer_center_update_action" = "Güncelle"; +"customer_center_update_continue" = "Devam et"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Yinelenen aboneliklerinize olabilir"; +"customer_center_duplicate_message" = "Hem web üzerinden hem de App Store üzerinden abone olmuş olabilirsiniz. İki kez ücretlendirilmemek için bunlardan birini iptal edin."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Destek talebi"; +"customer_center_support_body" = "Lütfen sorununuzu veya sorunuzu açıklayın."; +"customer_center_no_mail_app" = "Bu cihazda yapılandırılmış bir posta uygulaması yok. Bize %@ adresinden ulaşabilirsiniz."; +"customer_center_detail_nothing_to_manage" = "Bu satın alma için yönetilecek bir şey yok."; +"customer_center_detail_managed_through" = "Bu aboneliği %@ üzerinden yönetin."; +"customer_center_detail_managed_where_bought" = "Bu aboneliği satın aldığınız yerden yönetin."; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 85298a8eb2..712e3c7856 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Готово"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Керування підпискою"; +"customer_center_no_purchases_title" = "Підписок не знайдено"; +"customer_center_no_purchases_subtitle" = "Ми можемо перевірити попередні покупки."; +"customer_center_close" = "Закрити"; +"customer_center_done" = "Готово"; +"customer_center_cancel" = "Скасувати"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Відновити покупки"; +"customer_center_path_manage_subscription" = "Скасування підписки"; +"customer_center_path_manage_subscription_web" = "Керування підпискою"; +"customer_center_web_manage_unavailable" = "Керуйте підпискою за посиланням у квитанції, надісланій електронною поштою."; +"customer_center_path_refund" = "Запросити повернення коштів"; +"customer_center_path_change_plan" = "Змінити план"; +"customer_center_path_contact_support" = "Зв'язатися зі службою підтримки"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Чому ви скасовуєте?"; +"customer_center_survey_too_expensive" = "Занадто дорого"; +"customer_center_survey_dont_use" = "Не користуюся застосунком"; +"customer_center_survey_bought_by_mistake" = "Куплено помилково"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Поновлюється %@ за %@"; +"customer_center_renews_on" = "Поновлюється %@"; +"customer_center_expires_on" = "Закінчується %@"; +"customer_center_expired_on" = "Закінчилося %@"; +"customer_center_free_trial_until" = "Безкоштовний пробний період до %@"; +"customer_center_billing_issue" = "Проблема з оплатою – оновіть спосіб оплати, щоб зберегти доступ"; +"customer_center_lifetime" = "Довічний доступ"; +"customer_center_revoked" = "Кошти повернено"; +"customer_center_purchased_on" = "Придбано %@"; +"customer_center_active_via_superwall" = "Активна"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Закінчилася"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Активна"; +"customer_center_badge_free_trial" = "Безкоштовний пробний період"; +"customer_center_badge_cancelled" = "Скасовано"; +"customer_center_badge_billing_issue" = "Проблема з оплатою"; +"customer_center_badge_expired" = "Закінчилася"; +"customer_center_badge_revoked" = "Кошти повернено"; +"customer_center_badge_lifetime" = "Довічна"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Веб"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Інше"; +"customer_center_family_shared" = "Надано через сімейний доступ"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Підписки"; +"customer_center_section_purchases" = "Покупки"; +"customer_center_section_actions" = "Дії"; +"customer_center_account_details" = "Дані облікового запису"; +"customer_center_user_id" = "Ідентифікатор користувача"; +"customer_center_copy" = "Копіювати"; +"customer_center_copied" = "Скопійовано"; +"customer_center_original_download_date" = "Дата первинного завантаження"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Відновлення…"; +"customer_center_restore_success_title" = "Покупки відновлено"; +"customer_center_restore_success_message" = "Ми відновили ваші попередні покупки та застосували їх до вашого облікового запису."; +"customer_center_restore_none_title" = "Немає попередніх покупок"; +"customer_center_restore_none_message" = "Ми не знайшли жодних покупок для вашого облікового запису. Якщо ви вважаєте, що це помилка, зверніться до служби підтримки."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple отримала ваш запит на повернення коштів."; +"customer_center_refund_error" = "Під час запиту на повернення коштів сталася помилка. Спробуйте ще раз."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Доступне оновлення"; +"customer_center_update_message" = "Завантаження останньої версії застосунку може допомогти вирішити проблему."; +"customer_center_update_action" = "Оновити"; +"customer_center_update_continue" = "Продовжити"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "У вас можуть бути дубльовані підписки"; +"customer_center_duplicate_message" = "Можливо, ви підписані і в інтернеті, і через App Store. Щоб уникнути подвійного списання коштів, скасуйте одну з них."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Запит до підтримки"; +"customer_center_support_body" = "Будь ласка, опишіть вашу проблему або запитання."; +"customer_center_no_mail_app" = "На цьому пристрої не налаштовано жодного поштового застосунку. Ви можете зв'язатися з нами за адресою %@."; +"customer_center_detail_nothing_to_manage" = "Для цієї покупки немає чим керувати."; +"customer_center_detail_managed_through" = "Керуйте цією підпискою через %@."; +"customer_center_detail_managed_where_bought" = "Керуйте цією підпискою там, де ви її оформили."; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 98d1ac03e4..1a6ce050fc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "Xong"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Quản lý gói đăng ký của bạn"; +"customer_center_no_purchases_title" = "Không tìm thấy gói đăng ký nào"; +"customer_center_no_purchases_subtitle" = "Chúng tôi có thể kiểm tra các giao dịch mua trước đó."; +"customer_center_close" = "Đóng"; +"customer_center_done" = "Xong"; +"customer_center_cancel" = "Hủy"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "Khôi phục giao dịch mua"; +"customer_center_path_manage_subscription" = "Hủy gói đăng ký"; +"customer_center_path_manage_subscription_web" = "Quản lý gói đăng ký"; +"customer_center_web_manage_unavailable" = "Quản lý gói đăng ký của bạn bằng liên kết trong biên nhận gửi qua email."; +"customer_center_path_refund" = "Yêu cầu hoàn tiền"; +"customer_center_path_change_plan" = "Thay đổi gói"; +"customer_center_path_contact_support" = "Liên hệ hỗ trợ"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "Tại sao bạn hủy?"; +"customer_center_survey_too_expensive" = "Quá đắt"; +"customer_center_survey_dont_use" = "Không sử dụng ứng dụng"; +"customer_center_survey_bought_by_mistake" = "Mua nhầm"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "Gia hạn vào %@ với giá %@"; +"customer_center_renews_on" = "Gia hạn vào %@"; +"customer_center_expires_on" = "Hết hạn vào %@"; +"customer_center_expired_on" = "Đã hết hạn vào %@"; +"customer_center_free_trial_until" = "Dùng thử miễn phí đến %@"; +"customer_center_billing_issue" = "Sự cố thanh toán – cập nhật phương thức thanh toán để duy trì quyền truy cập"; +"customer_center_lifetime" = "Truy cập trọn đời"; +"customer_center_revoked" = "Đã hoàn tiền"; +"customer_center_purchased_on" = "Đã mua vào %@"; +"customer_center_active_via_superwall" = "Đang hoạt động"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "Đã hết hạn"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "Đang hoạt động"; +"customer_center_badge_free_trial" = "Dùng thử miễn phí"; +"customer_center_badge_cancelled" = "Đã hủy"; +"customer_center_badge_billing_issue" = "Sự cố thanh toán"; +"customer_center_badge_expired" = "Đã hết hạn"; +"customer_center_badge_revoked" = "Đã hoàn tiền"; +"customer_center_badge_lifetime" = "Trọn đời"; + +/* Customer Center – stores */ +"customer_center_store_web" = "Web"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "Khác"; +"customer_center_family_shared" = "Được chia sẻ qua Chia sẻ trong gia đình"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "Gói đăng ký"; +"customer_center_section_purchases" = "Giao dịch mua"; +"customer_center_section_actions" = "Thao tác"; +"customer_center_account_details" = "Chi tiết tài khoản"; +"customer_center_user_id" = "ID người dùng"; +"customer_center_copy" = "Sao chép"; +"customer_center_copied" = "Đã sao chép"; +"customer_center_original_download_date" = "Ngày tải xuống ban đầu"; + +/* Customer Center – restore */ +"customer_center_restoring" = "Đang khôi phục…"; +"customer_center_restore_success_title" = "Đã khôi phục giao dịch mua"; +"customer_center_restore_success_message" = "Chúng tôi đã khôi phục các giao dịch mua trước đây của bạn và áp dụng chúng vào tài khoản của bạn."; +"customer_center_restore_none_title" = "Không có giao dịch mua trước đó"; +"customer_center_restore_none_message" = "Chúng tôi không tìm thấy giao dịch mua nào cho tài khoản của bạn. Nếu bạn cho rằng đây là lỗi, vui lòng liên hệ bộ phận hỗ trợ."; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple đã nhận được yêu cầu hoàn tiền của bạn."; +"customer_center_refund_error" = "Đã xảy ra lỗi khi yêu cầu hoàn tiền. Vui lòng thử lại."; + +/* Customer Center – update warning */ +"customer_center_update_title" = "Có bản cập nhật"; +"customer_center_update_message" = "Tải xuống phiên bản mới nhất của ứng dụng có thể giúp giải quyết sự cố."; +"customer_center_update_action" = "Cập nhật"; +"customer_center_update_continue" = "Tiếp tục"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "Bạn có thể có các gói đăng ký trùng lặp"; +"customer_center_duplicate_message" = "Bạn có thể đã đăng ký cả trên web và qua App Store. Để tránh bị tính phí hai lần, hãy hủy một trong số đó."; + +/* Customer Center – support */ +"customer_center_support_subject" = "Yêu cầu hỗ trợ"; +"customer_center_support_body" = "Vui lòng mô tả vấn đề hoặc câu hỏi của bạn."; +"customer_center_no_mail_app" = "Không có ứng dụng thư nào được định cấu hình trên thiết bị này. Bạn có thể liên hệ với chúng tôi tại %@."; +"customer_center_detail_nothing_to_manage" = "Không có gì để quản lý cho giao dịch mua này."; +"customer_center_detail_managed_through" = "Quản lý gói đăng ký này qua %@."; +"customer_center_detail_managed_where_bought" = "Quản lý gói đăng ký này tại nơi bạn đã mua."; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index fdd2d4b7af..f2df86fc31 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "完成"; + +/* Customer Center – screens */ +"customer_center_management_title" = "管理您的订阅"; +"customer_center_no_purchases_title" = "未找到订阅"; +"customer_center_no_purchases_subtitle" = "我们可以检查以前的购买记录。"; +"customer_center_close" = "关闭"; +"customer_center_done" = "完成"; +"customer_center_cancel" = "取消"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "恢复购买项目"; +"customer_center_path_manage_subscription" = "取消订阅"; +"customer_center_path_manage_subscription_web" = "管理订阅"; +"customer_center_web_manage_unavailable" = "请通过电子邮件收据中的链接管理您的订阅。"; +"customer_center_path_refund" = "申请退款"; +"customer_center_path_change_plan" = "更改方案"; +"customer_center_path_contact_support" = "联系支持人员"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "您为什么要取消?"; +"customer_center_survey_too_expensive" = "太贵了"; +"customer_center_survey_dont_use" = "不使用该应用"; +"customer_center_survey_bought_by_mistake" = "误购"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "将于 %@ 以 %@ 续订"; +"customer_center_renews_on" = "将于 %@ 续订"; +"customer_center_expires_on" = "将于 %@ 到期"; +"customer_center_expired_on" = "已于 %@ 到期"; +"customer_center_free_trial_until" = "免费试用至 %@"; +"customer_center_billing_issue" = "账单问题 – 请更新您的付款方式以保留访问权限"; +"customer_center_lifetime" = "终身使用权"; +"customer_center_revoked" = "已退款"; +"customer_center_purchased_on" = "购买于 %@"; +"customer_center_active_via_superwall" = "有效"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "已过期"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "有效"; +"customer_center_badge_free_trial" = "免费试用"; +"customer_center_badge_cancelled" = "已取消"; +"customer_center_badge_billing_issue" = "账单问题"; +"customer_center_badge_expired" = "已过期"; +"customer_center_badge_revoked" = "已退款"; +"customer_center_badge_lifetime" = "终身"; + +/* Customer Center – stores */ +"customer_center_store_web" = "网页"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "其他"; +"customer_center_family_shared" = "通过家人共享获得"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "订阅"; +"customer_center_section_purchases" = "购买项目"; +"customer_center_section_actions" = "操作"; +"customer_center_account_details" = "账户详情"; +"customer_center_user_id" = "用户 ID"; +"customer_center_copy" = "复制"; +"customer_center_copied" = "已复制"; +"customer_center_original_download_date" = "首次下载日期"; + +/* Customer Center – restore */ +"customer_center_restoring" = "正在恢复…"; +"customer_center_restore_success_title" = "购买项目已恢复"; +"customer_center_restore_success_message" = "我们已恢复您以前的购买记录,并应用到您的账户。"; +"customer_center_restore_none_title" = "没有以前的购买记录"; +"customer_center_restore_none_message" = "未找到与您账户相关的任何购买记录。如果您认为这是错误,请联系支持人员。"; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple 已收到您的退款请求。"; +"customer_center_refund_error" = "申请退款时出现问题,请重试。"; + +/* Customer Center – update warning */ +"customer_center_update_title" = "有可用更新"; +"customer_center_update_message" = "下载该应用的最新版本可能有助于解决此问题。"; +"customer_center_update_action" = "更新"; +"customer_center_update_continue" = "继续"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "您可能有重复的订阅"; +"customer_center_duplicate_message" = "您可能同时通过网页和 App Store 订阅。为避免被重复扣费,请取消其中一个。"; + +/* Customer Center – support */ +"customer_center_support_subject" = "支持请求"; +"customer_center_support_body" = "请描述您的问题或疑问。"; +"customer_center_no_mail_app" = "此设备未配置邮件应用。您可以通过 %@ 联系我们。"; +"customer_center_detail_nothing_to_manage" = "此购买项目没有可管理的内容。"; +"customer_center_detail_managed_through" = "请通过%@管理此订阅。"; +"customer_center_detail_managed_where_bought" = "请在购买处管理此订阅。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index c11762ea5e..84c4557042 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -36,3 +36,95 @@ /* Generic alert dismiss button */ "alert_action_done" = "完成"; + +/* Customer Center – screens */ +"customer_center_management_title" = "管理您的訂閱"; +"customer_center_no_purchases_title" = "找不到訂閱"; +"customer_center_no_purchases_subtitle" = "我們可以查詢先前的購買記錄。"; +"customer_center_close" = "關閉"; +"customer_center_done" = "完成"; +"customer_center_cancel" = "取消"; + +/* Customer Center – paths */ +"customer_center_path_restore" = "恢復購買項目"; +"customer_center_path_manage_subscription" = "取消訂閱"; +"customer_center_path_manage_subscription_web" = "管理訂閱"; +"customer_center_web_manage_unavailable" = "請透過電子郵件收據中的連結管理您的訂閱。"; +"customer_center_path_refund" = "申請退款"; +"customer_center_path_change_plan" = "變更方案"; +"customer_center_path_contact_support" = "聯絡支援人員"; + +/* Customer Center – default survey */ +"customer_center_survey_cancel_title" = "您為什麼要取消?"; +"customer_center_survey_too_expensive" = "太貴了"; +"customer_center_survey_dont_use" = "不使用該應用程式"; +"customer_center_survey_bought_by_mistake" = "誤購"; + +/* Customer Center – purchase status */ +"customer_center_renews_on_for" = "將於 %@ 以 %@ 續訂"; +"customer_center_renews_on" = "將於 %@ 續訂"; +"customer_center_expires_on" = "將於 %@ 到期"; +"customer_center_expired_on" = "已於 %@ 到期"; +"customer_center_free_trial_until" = "免費試用至 %@"; +"customer_center_billing_issue" = "帳單問題 – 請更新您的付款方式以保留存取權限"; +"customer_center_lifetime" = "終身使用權"; +"customer_center_revoked" = "已退款"; +"customer_center_purchased_on" = "購買於 %@"; +"customer_center_active_via_superwall" = "有效"; +"customer_center_price_per_period" = "%@ / %@"; +"customer_center_expired" = "已過期"; + +/* Customer Center – badges */ +"customer_center_badge_active" = "有效"; +"customer_center_badge_free_trial" = "免費試用"; +"customer_center_badge_cancelled" = "已取消"; +"customer_center_badge_billing_issue" = "帳單問題"; +"customer_center_badge_expired" = "已過期"; +"customer_center_badge_revoked" = "已退款"; +"customer_center_badge_lifetime" = "終身"; + +/* Customer Center – stores */ +"customer_center_store_web" = "網頁"; +"customer_center_store_google_play" = "Google Play"; +"customer_center_store_superwall" = "Superwall"; +"customer_center_store_other" = "其他"; +"customer_center_family_shared" = "透過家人共享取得"; + +/* Customer Center – sections */ +"customer_center_section_subscriptions" = "訂閱"; +"customer_center_section_purchases" = "購買項目"; +"customer_center_section_actions" = "操作"; +"customer_center_account_details" = "帳戶詳情"; +"customer_center_user_id" = "使用者 ID"; +"customer_center_copy" = "複製"; +"customer_center_copied" = "已複製"; +"customer_center_original_download_date" = "首次下載日期"; + +/* Customer Center – restore */ +"customer_center_restoring" = "正在恢復…"; +"customer_center_restore_success_title" = "購買項目已恢復"; +"customer_center_restore_success_message" = "我們已恢復您先前的購買記錄,並套用到您的帳戶。"; +"customer_center_restore_none_title" = "沒有先前的購買記錄"; +"customer_center_restore_none_message" = "找不到與您帳戶相關的任何購買記錄。如果您認為這是錯誤,請聯絡支援人員。"; + +/* Customer Center – refund */ +"customer_center_refund_success" = "Apple 已收到您的退款請求。"; +"customer_center_refund_error" = "申請退款時發生問題,請重試。"; + +/* Customer Center – update warning */ +"customer_center_update_title" = "有可用更新"; +"customer_center_update_message" = "下載該應用程式的最新版本或許有助於解決此問題。"; +"customer_center_update_action" = "更新"; +"customer_center_update_continue" = "繼續"; + +/* Customer Center – duplicate subscriptions */ +"customer_center_duplicate_title" = "您可能有重複的訂閱"; +"customer_center_duplicate_message" = "您可能同時透過網頁和 App Store 訂閱。為避免被重複扣款,請取消其中一項。"; + +/* Customer Center – support */ +"customer_center_support_subject" = "支援請求"; +"customer_center_support_body" = "請描述您的問題或疑問。"; +"customer_center_no_mail_app" = "此裝置未設定郵件應用程式。您可以透過 %@ 與我們聯絡。"; +"customer_center_detail_nothing_to_manage" = "此購買項目沒有可管理的內容。"; +"customer_center_detail_managed_through" = "請透過%@管理此訂閱。"; +"customer_center_detail_managed_where_bought" = "請在購買處管理此訂閱。"; diff --git a/Sources/SuperwallKit/Storage/Cache/Cache.swift b/Sources/SuperwallKit/Storage/Cache/Cache.swift index 022be9aa2e..db8f5b84aa 100644 --- a/Sources/SuperwallKit/Storage/Cache/Cache.swift +++ b/Sources/SuperwallKit/Storage/Cache/Cache.swift @@ -28,24 +28,39 @@ class Cache { /// Size is allocated for disk cache, in byte. 0 mean no limit. Default is 0 private var maxDiskCacheSize: UInt = 0 + /// The cache used when none is injected. Every instance shares the same on-disk directories, + /// which makes concurrently running unit tests contaminate each other's storage — so under + /// the test runner each defaulted cache gets its own directories. Tests that exercise the + /// real directory layout construct `Cache` directly. + static func makeDefault() -> Cache { + if ProcessInfo.processInfo.arguments.contains("SUPERWALL_UNIT_TESTS") { + return Cache(directoryNamespace: UUID().uuidString) + } + return Cache() + } + /// Specify distinct name param, it represents folder name for disk cache init( fileManager: FileManager = FileManager(), - ioQueue: DispatchQueue = DispatchQueue(label: Cache.ioQueuePrefix) + ioQueue: DispatchQueue = DispatchQueue(label: Cache.ioQueuePrefix), + directoryNamespace: String? = nil ) { self.fileManager = fileManager + func namespaced(_ prefix: String) -> String { + directoryNamespace.map { "\(prefix)-\($0)" } ?? prefix + } cacheUrl = fileManager .urls(for: .cachesDirectory, in: .userDomainMask) .first? - .appendingPathComponent(Cache.cacheDirectoryPrefix) + .appendingPathComponent(namespaced(Cache.cacheDirectoryPrefix)) userSpecificDocumentUrl = fileManager .urls(for: .applicationSupportDirectory, in: .userDomainMask) .first? - .appendingPathComponent(Cache.userSpecificDocumentDirectoryPrefix) + .appendingPathComponent(namespaced(Cache.userSpecificDocumentDirectoryPrefix)) appSpecificDocumentUrl = fileManager .urls(for: .applicationSupportDirectory, in: .userDomainMask) .first? - .appendingPathComponent(Cache.appSpecificDocumentDirectoryPrefix) + .appendingPathComponent(namespaced(Cache.appSpecificDocumentDirectoryPrefix)) self.ioQueue = ioQueue diff --git a/Sources/SuperwallKit/Storage/Storage.swift b/Sources/SuperwallKit/Storage/Storage.swift index 895fa38c47..170016dc63 100644 --- a/Sources/SuperwallKit/Storage/Storage.swift +++ b/Sources/SuperwallKit/Storage/Storage.swift @@ -81,7 +81,7 @@ class Storage { init( factory: DeviceHelperFactory & HasExternalPurchaseControllerFactory, - cache: Cache = Cache(), + cache: Cache = .makeDefault(), coreDataManager: CoreDataManager = CoreDataManager() ) { self.cache = cache diff --git a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift index 2cb565fb4a..222f1b445b 100644 --- a/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift +++ b/Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift @@ -461,6 +461,12 @@ public final class StoreProduct: NSObject, StoreProductType, Sendable { self.init(testProduct) } + /// A product from the Superwall catalogue rather than a store. Used where StoreKit can't supply + /// one — a web (Stripe/Paddle) purchase, say, whose price only exists in the catalogue. + convenience init(catalogProduct: APIStoreProduct) { + self.init(catalogProduct) + } + convenience init(customProduct: APIStoreProduct) { self.init(customProduct) self.isCustomProduct = true diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 4ee53ed3eb..474b482c66 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -192,7 +192,10 @@ final class TransactionManager { @MainActor @discardableResult - func tryToRestore(_ restoreSource: RestoreSource) async -> RestorationResult { + func tryToRestore( + _ restoreSource: RestoreSource, + presentsFailureAlert: Bool = true + ) async -> RestorationResult { func logAndTrack( state: InternalSuperwallEvent.Restore.State, message: String, @@ -403,12 +406,14 @@ final class TransactionManager { .webRestore: break case .failure: - await presentAlert( - title: Superwall.shared.options.paywalls.restoreFailed.title, - message: Superwall.shared.options.paywalls.restoreFailed.message, - closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle, - source: restoreSource - ) + if presentsFailureAlert { + await presentAlert( + title: Superwall.shared.options.paywalls.restoreFailed.title, + message: Superwall.shared.options.paywalls.restoreFailed.message, + closeActionTitle: Superwall.shared.options.paywalls.restoreFailed.closeButtonTitle, + source: restoreSource + ) + } } return restorationResult diff --git a/Sources/SuperwallKit/Superwall+CustomerCenter.swift b/Sources/SuperwallKit/Superwall+CustomerCenter.swift new file mode 100644 index 0000000000..2d32263811 --- /dev/null +++ b/Sources/SuperwallKit/Superwall+CustomerCenter.swift @@ -0,0 +1,95 @@ +// +// Superwall+CustomerCenter.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import UIKit + +extension Superwall { + /// Presents the Customer Center, a self-service screen where users can view and manage their + /// subscriptions, request refunds, restore purchases, and contact support. + /// + /// Only one Customer Center can be presented at a time; calling this while one is already + /// presented is a no-op. + /// + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter`` for this presentation. `nil` + /// uses the value configured via `SuperwallOptions`. + /// - presenter: The view controller to present from. `nil` presents from the top-most + /// currently-presented view controller. + /// - delegate: Receives Customer Center events. Strongly retained for the duration of the + /// presentation. + /// - onDismiss: Called after the Customer Center is dismissed. + @available(iOS 15.0, *) + @MainActor + public func presentCustomerCenter( + configuration: CustomerCenterConfiguration? = nil, + from presenter: UIViewController? = nil, + delegate: CustomerCenterDelegate? = nil, + onDismiss: (() -> Void)? = nil + ) { + guard Superwall.isInitialized else { + Logger.debug( + logLevel: .error, + scope: .customerCenter, + message: "Superwall has not been configured. Please call Superwall.configure() first." + ) + return + } + dependencyContainer.customerCenterManager.present( + configuration: configuration, + from: presenter, + delegate: delegate, + onDismiss: onDismiss + ) + } + + /// Dismisses a Customer Center presented via + /// ``presentCustomerCenter(configuration:from:delegate:onDismiss:)``. A no-op if none is presented. + /// + /// Available to Objective-C as `dismissCustomerCenterWithCompletion:`. + @available(iOS 15.0, *) + @MainActor + @objc(dismissCustomerCenterWithCompletion:) + public func dismissCustomerCenter(completion: (() -> Void)? = nil) { + guard Superwall.isInitialized else { + Logger.debug( + logLevel: .error, + scope: .customerCenter, + message: "Superwall has not been configured. Please call Superwall.configure() first." + ) + return + } + dependencyContainer.customerCenterManager.dismiss(completion: completion) + } + + /// Objective-C: presents the Customer Center. See + /// ``presentCustomerCenter(configuration:from:delegate:onDismiss:)``. + @available(iOS 15.0, *) + @available(swift, obsoleted: 1.0) + @MainActor + @objc(presentCustomerCenterWithConfiguration:from:delegate:onDismiss:) + public func presentCustomerCenterObjc( + configuration: CustomerCenterConfiguration?, + from presenter: UIViewController?, + delegate: CustomerCenterDelegateObjc?, + onDismiss: (() -> Void)? + ) { + guard Superwall.isInitialized else { + Logger.debug( + logLevel: .error, + scope: .customerCenter, + message: "Superwall has not been configured. Please call Superwall.configure() first." + ) + return + } + dependencyContainer.customerCenterManager.presentObjc( + configuration: configuration, + from: presenter, + objcDelegate: delegate, + onDismiss: onDismiss + ) + } +} diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index 6148926d61..be5bac0163 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1324,11 +1324,26 @@ public final class Superwall: NSObject, ObservableObject { /// see an alert if ``Superwall/subscriptionStatus`` is not ``SubscriptionStatus/active`` /// after returning this value. public func restorePurchases() async -> RestorationResult { + return await restorePurchases(presentsFailureAlert: true) + } + + /// Restores purchases, optionally suppressing the SDK's own restore-failure alert. + /// + /// Used internally by callers — such as the Customer Center — that present their own + /// restore-outcome UI and don't want the SDK's alert doubling up with theirs. + /// + /// - Parameter presentsFailureAlert: When `false`, suppresses the SDK's built-in + /// restore-failure alert on failure. Defaults to `true` for the public API. + /// - Returns: A ``RestorationResult`` object that defines if the restoration was successful or not. + func restorePurchases(presentsFailureAlert: Bool) async -> RestorationResult { // Await config because we must have entitlements before restoring. _ = try? await dependencyContainer.configManager.configState .compactMap { $0.getConfig() } .throwableAsync() - let result = await dependencyContainer.transactionManager.tryToRestore(.external) + let result = await dependencyContainer.transactionManager.tryToRestore( + .external, + presentsFailureAlert: presentsFailureAlert + ) return result } diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index b031453c92..a7fa74956a 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.16.4" + s.version = "4.17.0" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 817f1bf3cc..a2d4a972c9 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 0A1366F15DD3C1761C095DF5 /* SuperwallOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */; }; 0A35D3D8CCC8D043628A0A4F /* TrackingAuthorizationStatusConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC92F1146FA9FA76AF25227 /* TrackingAuthorizationStatusConversionTests.swift */; }; 0A5EFFC920E6BB29814BD66B /* Foundation+ASN1Coder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80281364A23252E30DEEA1AA /* Foundation+ASN1Coder.swift */; }; + 0A6711CD579DFCD3F0E4F263 /* PathTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */; }; 0AB9CCC164DD87C81318AAB0 /* PublicIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */; }; 0B5A0C6EA2D1C98B32110FD9 /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */; }; 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F538F901BAF97DDCA86F84 /* DeviceHelperMock.swift */; }; @@ -42,6 +43,7 @@ 0EB256F6E5E6B608878941ED /* UIWindow+Landscape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA65A320EE640CDB878F43E9 /* UIWindow+Landscape.swift */; }; 0EF8D358CA712DB3C45C1318 /* ConfirmHoldoutAssignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */; }; 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC67D2CEA90B70D6AC99419 /* PurchaseControllerObjc.swift */; }; + 0F6EB7DF5B8373B4718D00B9 /* AppStoreUpdateCheckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */; }; 11477D1EB60D1FDA32F5099A /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258FC2DB67022EF3D9B1FB67 /* Endpoint.swift */; }; 11719638C88CFCA506264531 /* PopupTransitionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13CC9902419E7D68B47C184 /* PopupTransitionDelegate.swift */; }; 11798EDE58E5D225E5414F2E /* FakeLocationAuthorizationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D198C8645A213EEAD622C881 /* FakeLocationAuthorizationStatus.swift */; }; @@ -86,16 +88,21 @@ 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.swift */; }; 23CD6038DD65F057C81A412D /* PaywallManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */; }; 2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA9100DDAD2E8596F96A1BCB /* Assignment.swift */; }; + 24D4B587A3D2FF168587117F /* PurchaseDetailActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */; }; 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */; }; 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655E5AE73EF5723A28D2EADD /* SurveyTests.swift */; }; 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E541F079BC78206BC44D6E /* TemplateLogic.swift */; }; + 26081D80FCF7BCD475103467 /* CustomerCenterManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19BDEFD1BAB331A814E95CE /* CustomerCenterManagerTests.swift */; }; 26237FCC56AE2B7B68C9F1B1 /* SWWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */; }; + 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */; }; 2653909358966BE9AC9894F1 /* EvaluationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 911CD5859EC1BE7E428F06C4 /* EvaluationResult.swift */; }; 2698874EEAE37BAECE7B8FD8 /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79E2143AD65D151AE7A4BF0F /* Network.swift */; }; 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AC1F76564A6EC47EE696F9 /* DevicePreloadScriptTests.swift */; }; 27DC2F109FAE3357DC8418F6 /* AutomaticPurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1F47410CBC84D9ABD2F14 /* AutomaticPurchaseController.swift */; }; 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58BA95995DE57E811FD65C02 /* PaywallCacheLogicTests.swift */; }; + 28A1E7AFF222A9C36F4AE019 /* Superwall+CustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618F0D37FD3B2213A296DEF4 /* Superwall+CustomerCenter.swift */; }; 28FED9AE68193B568FF887E1 /* Superscript in Frameworks */ = {isa = PBXBuildFile; productRef = 721C720FA8360B9851DE843D /* Superscript */; }; + 295EF01B171923E20329DF91 /* CustomerCenterManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165B7C00C437146FB5C9DB92 /* CustomerCenterManager.swift */; }; 29EE3ACBAA5A7D7DA1269C65 /* String+ROT13.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9845F441ACFCBC267E3368C5 /* String+ROT13.swift */; }; 2A07A8F4A55E28D13777D03E /* CheckDebuggerPresentationOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E815C43EB5A02E48B618DD9F /* CheckDebuggerPresentationOperatorTests.swift */; }; 2A1087A991658780B2B8036F /* PushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1E7DA9C8816082FA05B84D /* PushTransition.swift */; }; @@ -113,16 +120,20 @@ 2EC1D279019CD3FB64E4674A /* TriggerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25515131DF0AE67E26BFF462 /* TriggerResult.swift */; }; 2F33D9FC5A40496D6922CEBB /* IARError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFE7B1045C0541E66A965FC1 /* IARError.swift */; }; 2F54D64CED54E63F0E7B8711 /* AudioSessionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7E0E27369A406D3492A11E2 /* AudioSessionProxy.swift */; }; + 2F74D47847F140EFCAF1C814 /* PathsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */; }; 3002A50E92B640B4E3A98662 /* SuperwallKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 04FB15C76DE3D22CB370AFDB /* SuperwallKit.framework */; }; 30113C71D033ADDF01214C75 /* PreloadingDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */; }; 309EC3675C7EF75050B076E7 /* ComputedPropertyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.swift */; }; + 30C0236F4E6A5260BFEF59AF /* CustomerCenterPushNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */; }; 31DE588B2B4A26745C33753C /* ThrowableDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */; }; 31E937EB414F62268F6C953C /* TestModeInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3548435BDE49161E3BDFA358 /* TestModeInfoCell.swift */; }; + 32A52161B29C999F06217B9A /* AppUpdateWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */; }; 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1E17A7907C42F27817C958 /* NonSubscriptionTransaction.swift */; }; 3313CD30A969731960FC32BF /* PaywallOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1439A212719AA2EA8BEA357 /* PaywallOverrides.swift */; }; 339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; }; 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; + 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; @@ -138,12 +149,14 @@ 3BCCE08DF16DC2D16F9AB490 /* UIView+SpringAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F636156244B674A56ADC461C /* UIView+SpringAnimation.swift */; }; 3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEA21AF7F6C5FB0EC83E4E1A /* PresentPaywallOperatorTests.swift */; }; 3C21624B627B249FB3B681FB /* SwiftVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */; }; + 3C9432E5304D2C50E5B2B06F /* PurchasePresentationBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */; }; 3CB65E105ADED11AEE69DEAF /* InAppReceiptAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9553EC1E394EF7AE8788291 /* InAppReceiptAttribute.swift */; }; 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */; }; 3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; }; 3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; }; 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; }; 3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; }; + 3F203A8C5B6DD47D33B6C516 /* CustomerCenterDelegateAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */; }; 3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; }; 3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */; }; 3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D685A5912892EF9C2931B1 /* V4Migrator.swift */; }; @@ -160,13 +173,19 @@ 44829144E9EFA0CE4A75BBA1 /* ExpressionLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */; }; 44E2AE9B0AED16C48027CD21 /* CustomCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = E439B70BB6190AFF6DDB81F2 /* CustomCallback.swift */; }; 454421E34ED200400A001AE1 /* PaywallManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.swift */; }; + 4616C021DCB8228B75D8B591 /* CustomerCenterNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */; }; + 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */; }; 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; + 481903391564D2B19A9BD285 /* CustomerCenterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */; }; 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */; }; 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */; }; + 4A270686A4C804CDC85FB5B8 /* WebSubscriptionPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C262ABB80CCC8266542F26C5 /* WebSubscriptionPathTests.swift */; }; + 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */; }; 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */; }; 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */; }; 4AA4E2CE223DC7CF1678E83C /* TrackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E23B703C00044332FDEBE8 /* TrackTests.swift */; }; 4AB907436D4A84932F09D8B3 /* String+MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = D449672964023589DA5535E3 /* String+MD5.swift */; }; + 4ADD216B493FF99AE4404438 /* StoreKitTransactionLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F084F94D853AA5B5CC79 /* StoreKitTransactionLookup.swift */; }; 4B0E203D477E48611797047C /* PaywallViewControllerCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 672776875A4286319C2F2D61 /* PaywallViewControllerCacheTests.swift */; }; 4B4BCB32699C3A1AF7E2BFE6 /* SK2StoreProductCyclesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */; }; 4B54BA9E52A97C486D808A05 /* IntroOfferToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */; }; @@ -185,20 +204,26 @@ 5338AD57C30507242FFC0A39 /* TestModeManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */; }; 533E3B63BDCED62B3BD3C662 /* StoreProductDiscountType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 182DFFCC0B7AAA4C67C4079D /* StoreProductDiscountType.swift */; }; 534E94DCCD72F2F7D0EC1441 /* Task+Retrying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938EB5121B1D9EA6B2EAE9EC /* Task+Retrying.swift */; }; + 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */; }; 5566DBCF96993C1E4D217F50 /* GetPaywallResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24EA03270476CD31B906CDC8 /* GetPaywallResult.swift */; }; 556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */; }; + 5578870EF7D736E46CC8E828 /* CustomerCenterConfiguration+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F86C1F3253E4C8FFBCB30AC /* CustomerCenterConfiguration+Appearance.swift */; }; 558A89440F2E1B052316FE57 /* LogPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115F0BE94943D7B60CDDD4A /* LogPresentation.swift */; }; 5621A2D2FEC048847E22BF6C /* KeypathWritable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */; }; 5634C4E0E082754F7939BB60 /* ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B730226BC4F32B0A3A0FA6E9 /* ReceiptManager.swift */; }; 56408549E721E2ED524DEA35 /* PaddingListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC4D23D1EDDA249C928930D /* PaddingListener.swift */; }; 577B6D9068BAE0B1A87C8D64 /* StripeProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3479E4B3365290BC0C0A123 /* StripeProduct.swift */; }; + 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */; }; 58170B6B2E4224AD27549567 /* UserInitiatedEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE406AAED11F2B63E4A5A1FD /* UserInitiatedEvents.swift */; }; 58185F7A0770111BDE259936 /* NetworkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A2A54314BAEAF65B46D322 /* NetworkTests.swift */; }; 591DCE67E64C63AACFFB604B /* IdentityLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB3F04AC933701EE33F5F325 /* IdentityLogic.swift */; }; 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3501D12845840D6CBA1F0081 /* AssignmentLogicTests.swift */; }; + 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */; }; 5A6D06700C4E4E2C6C9BC1B2 /* ShimmerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D123D95036ED6CE3B097BBF0 /* ShimmerView.swift */; }; + 5B254755EE51075D28EA9282 /* AppVersionComparatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */; }; 5C504112376B6E0798CA20CE /* Variables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B75209DF76859131941CA0F /* Variables.swift */; }; 5D0DAFA97F75920FFB99DF6B /* PriceFormatterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */; }; + 5D1F0BE78AFD0801B6073B4A /* CustomerCenterDependenciesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */; }; 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */; }; 5DE5CE789559545FF1A8AD12 /* AppStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18E059F7745769ABCA0F2A99 /* AppStoreProduct.swift */; }; 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBFB0A38F634286F61572C51 /* ActivityIndicatorView.swift */; }; @@ -218,10 +243,12 @@ 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD4E7007670C369DD8FF5D9 /* Date+IsWithinAnHourBeforeTests.swift */; }; 654A73B0F1E27315DB1AE2D4 /* Redeemable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E232690489360042465DB2 /* Redeemable.swift */; }; 65F02A298EC782E84EE2D1D0 /* EntitlementsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */; }; + 664F2F91821AC7E9E80756CF /* PurchaseCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */; }; 666FBAEC100FD378E9EC816D /* EntitlementsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */; }; 67C020751429B5677D9A0727 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236900A8A8F95CE92E612458 /* IdentityManager.swift */; }; 67DE6918459F0E911D4D2D26 /* LogErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4F7C1AA96162D7C97493E /* LogErrors.swift */; }; 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */; }; + 684EE8F6BFE518BA5952E3B5 /* StoreKitTransactionLookupMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF4B8468A75C8948EEFC6D4E /* StoreKitTransactionLookupMock.swift */; }; 6897B0B9E3BC760FBCA2AB7C /* InternalPurchaseController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26B5B7A4C7137EB233CC1262 /* InternalPurchaseController.swift */; }; 68AF64973AC860BE2A41B8D4 /* LoadingInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57478172574516BD5EDD254A /* LoadingInfo.swift */; }; 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618BF4D10B7D87FAF8FB48CD /* ProductPurchaserSK1Tests.swift */; }; @@ -262,12 +289,15 @@ 77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */; }; 77FF632568317C7745451D67 /* ConfirmPaywallAssignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F7B4D9230BF922AE19A830 /* ConfirmPaywallAssignment.swift */; }; 78113F737BA99A2848850904 /* TrackableSuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */; }; + 781A9E4339F865190C8A5D6A /* CustomerCenterDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632BA7AFDDD93F08252C9043 /* CustomerCenterDelegateAdapter.swift */; }; 7909E7B477A2E2EBF84598F2 /* InternallySetSubscriptionStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */; }; 795E7752217DF07AD7EB8660 /* Trigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2628BCB13E80DC539F35C7B5 /* Trigger.swift */; }; 79E35504745555BC5CA14360 /* SK1ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */; }; 7A7D4424C0987AE40B61575E /* StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CDDA18AABDC7C71ECB7D0FA /* StoreProductDiscount.swift */; }; 7A810CAE7DEB417315A9CE82 /* StripeProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE89E115B095A63FAC09719 /* StripeProductType.swift */; }; 7AD6B818E94D31DD9E1F67BB /* InAppPurchase.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0A461D50AF945239D3D048 /* InAppPurchase.swift */; }; + 7C56FE8873F3E1E57905BC91 /* CustomerCenterViewModel+UpdateBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A6D71D51DB3F1BCE3E167B /* CustomerCenterViewModel+UpdateBanner.swift */; }; + 7CB32020EFC0785659ADA76C /* ManagementScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */; }; 7CC56E289C0A1C93411B68D2 /* PaywallViewControllerDrawerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 811F37DA54E0070E2F843021 /* PaywallViewControllerDrawerTests.swift */; }; 7D47BABD89CE33CDD78DFCC6 /* TestFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8AC8C252F503E7F1BBD47B /* TestFileManager.swift */; }; 7DA2CF4C6FF5C8A1C44282E6 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94120D51C7B36AC9EA32B8B /* LoadingView.swift */; }; @@ -280,6 +310,7 @@ 803BFA630F96B638E3BDE715 /* GameControllerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */; }; 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */; }; 81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; }; + 82060DEBCCB16E69508D249D /* WebProductPricingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */; }; 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; 842BD9930498E943061A9B8F /* APIStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2489DE003DCA646B562A200 /* APIStoreProduct.swift */; }; 84616856D40F775122FD9BF9 /* Dictionary+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */; }; @@ -287,10 +318,12 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; + 85F77E1A644AC7049D341D31 /* CatalogueCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */; }; 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; 88D22C84ACDED44E3952C786 /* SK2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */; }; + 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */; }; 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */; }; 8ACC4731031DA94C709915CF /* Transaction+LatestSince.swift in Sources */ = {isa = PBXBuildFile; fileRef = F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */; }; 8AEB577682D9AB9354CB8EE9 /* UIColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888B05A273E9EB6E9382332 /* UIColor+Hex.swift */; }; @@ -299,6 +332,7 @@ 8BA210D88B69EA78419354E1 /* InternalPresentationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */; }; 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */; }; 8C3A81E3D75F027539933310 /* BottomPaddingAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */; }; + 8E04DDF7FA8D6A76E96378F3 /* DesignReviewSnapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */; }; 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */; }; 8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */; }; 8F18BFB254E432BFBEAB1324 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3A82C80F89023672D56AD7 /* LogLevel.swift */; }; @@ -308,11 +342,14 @@ 919A08D7F25BD2DF27A22697 /* StorageMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D1887F247BF6F770122F257 /* StorageMock.swift */; }; 91BA5E01D0FB528954ABB937 /* StripeStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD42859B8BFEC078665FA1E /* StripeStoreProductDiscount.swift */; }; 9304297F3B76DB512F2F9D53 /* TrackingLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2300AFFC31667E749E85EAC /* TrackingLogicTests.swift */; }; + 93102FF82C63E6A2C5C6EBB9 /* FeedbackSurveyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B079AAB038F2DE800E71AD8 /* FeedbackSurveyView.swift */; }; 941F2296F5250A15DE6B5B70 /* SuperwallKit_Model.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */; }; + 94209E030EB310AAE5450272 /* SupportEmailComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */; }; 94908C7FD2227D917187FEEF /* CoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3AC3B23DAAA8C1D125BDD3 /* CoreDataManager.swift */; }; 9509D1E5080DBB8BD39FDF1C /* SuperwallKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 04FB15C76DE3D22CB370AFDB /* SuperwallKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 9532DC347593689DCDDBA1A4 /* StorePresentationObjects.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0596DAAE31B2242A59060C5F /* StorePresentationObjects.swift */; }; 953BB5825DA956E4BBE841B9 /* PaywallPresentationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65CEB049E29538C699F6EF8 /* PaywallPresentationInfo.swift */; }; + 959F8F9F86BD7E770D842FE3 /* CustomerCenterViewSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */; }; 96949F05ACC88C4475BB61EC /* GetPaywallComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EF6F057A58A42193F279BE6 /* GetPaywallComponents.swift */; }; 96FDC10323489C2622EBD563 /* Entitlement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 368EF475049935105AF8154C /* Entitlement.swift */; }; 9735942D34369DA4412F8B63 /* PermissionHandler+Microphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */; }; @@ -323,6 +360,7 @@ 999CEB0F1A2C8A7CAEE831BB /* TestModeTransactionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6595F247B4839C0AE082224B /* TestModeTransactionHandler.swift */; }; 9A0D436A679DD6FC72BEBE9A /* VerificationResult+Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C8B2F4853060258BC2CBD9 /* VerificationResult+Transaction.swift */; }; 9A802A666FD3BEE9B246EF4B /* ProductTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B430DE1BA468E280567F03C /* ProductTemplate.swift */; }; + 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B5BF7B93E59438467DB6C7 /* CustomerCenterScreenState.swift */; }; 9ABF8B3F8E320024252036F8 /* UNUserNotificationCenter+SuperwallNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE32816F1CA1637897AC87A2 /* UNUserNotificationCenter+SuperwallNotifications.swift */; }; 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81BE917F0AA7A7453B7D0BB2 /* AppSessionLogicTests.swift */; }; 9BBBEC1BD69C63BC3B082FDA /* PaywallLoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E703895B07CF172665846 /* PaywallLoadingState.swift */; }; @@ -336,6 +374,7 @@ 9F50FBB5826FF0A4ED075F26 /* ASN1Decoder+Extras.swift in Sources */ = {isa = PBXBuildFile; fileRef = D07ACB41E733B6CF8EA722E0 /* ASN1Decoder+Extras.swift */; }; 9F517D76DDEFF5278DDBACC8 /* V3Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891BDDF19DEC970709DDF4BB /* V3Migrator.swift */; }; 9FF0386DF5E4DFB59CF39B8B /* StoreTransactionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29E672B0703E6D85A3C65888 /* StoreTransactionType.swift */; }; + A028BE1961AF337DD06104F6 /* RestoreOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C515E4FDA405CBFADFA9FAB /* RestoreOverlay.swift */; }; A03AC977AD8110290DABECBD /* EntitlementPriorityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */; }; A138759EEB6B669C19FB5AFF /* PaywallMessageHandlerDelegateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F798D9662212AD1CC75666F3 /* PaywallMessageHandlerDelegateMock.swift */; }; A1621A749D8F05959A486ACE /* CoreDataManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1B228FB279EA92C35C7394 /* CoreDataManagerMock.swift */; }; @@ -347,7 +386,9 @@ A2DC9FA3045DF056BC867D8B /* PaywallPresentationStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */; }; A2DF1D9E1591874F082E6848 /* AudienceAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86C79B54278BF17FB1117E1 /* AudienceAttributes.swift */; }; A3A0961A4A230C10B8896400 /* PopupTransitionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BFFA527207A52EB7C70CAD4 /* PopupTransitionTests.swift */; }; + A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */; }; A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD6BA222CB2EAA4B65F362C5 /* ProductsFetcherSK1.swift */; }; + A50C9EFDE9ED8778BB4C44D3 /* NoPurchasesScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95FB737271FE6289A0A4BD1 /* NoPurchasesScreenView.swift */; }; A51060CF6339BF9383F94B51 /* MockSubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */; }; A59E22688D68CBE09FF78D57 /* IntroOfferEligibilityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08AEAA8E3B5F51848523AE61 /* IntroOfferEligibilityRequest.swift */; }; A646BB605400E4BDD321F389 /* SurveyShowCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E2D026C30691F11D4E839F /* SurveyShowCondition.swift */; }; @@ -364,9 +405,11 @@ AACC7BEE37DDDD7068A1E48C /* TransactionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646E6799FE4934BF76A06F34 /* TransactionManager.swift */; }; ABC17AE96AD396607E3CAB17 /* CoreDataStackMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */; }; AC0AF760E7EA2FFF5621955D /* PurchaseControllerObjcAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8F4169717A629E15CEB9C7 /* PurchaseControllerObjcAdapter.swift */; }; + AC13B2D29FF61BDC995132DD /* AccountDetailsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */; }; AC7D527612F631AAADC7D225 /* FileManagerMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB3F2FC9FCCD4B912E61A1F /* FileManagerMigratorTests.swift */; }; AD26500C2B27829305F76859 /* EndpointKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15E6FBB3D0826827A04F87AE /* EndpointKind.swift */; }; AD5EBB6DBA919E3CBC5B85B7 /* SessionEventsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D6FA6547CB5B9520B0B64 /* SessionEventsRequest.swift */; }; + ADFFF22341B34F9F28FE595B /* SupportEmailComposerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */; }; AE0555AA0B433427E5D17309 /* InternalGetPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57F454704875FFFC5CE1827 /* InternalGetPresentationResult.swift */; }; AE1D15070BC159212967CAD4 /* ConfirmHoldoutAssignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 018F5856F39FC33AFE9740D4 /* ConfirmHoldoutAssignmentTests.swift */; }; AE9F583082A5CDCE595BDA2D /* AppSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8165DD71323903F7CF426D2C /* AppSession.swift */; }; @@ -374,6 +417,8 @@ AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; }; AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */; }; AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; }; + B02D152D88E005E08078DA51 /* CustomerCenterViewModel+PurchaseDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */; }; + B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; B0B0AD9409CEFE7CA8225146 /* Array+SafeRemove.swift in Sources */ = {isa = PBXBuildFile; fileRef = C855DE8F5341D67C614E3AF5 /* Array+SafeRemove.swift */; }; @@ -399,16 +444,20 @@ B84CA6014D8D6EF201DB3935 /* LocalizationGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABC1253C6D5DD8D967BE05D1 /* LocalizationGrouping.swift */; }; B89435087910E6B501471622 /* Email.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BDB77756A3775FF4ED31C48 /* Email.swift */; }; B91D4755E1FDCBBC2D3CD8C3 /* InternalPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36299FDDEC7022F0F45A801 /* InternalPresentation.swift */; }; + BA0F56BF5C028624554EEC89 /* CustomerCenterViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F30029EE3419EE5BD4CD2948 /* CustomerCenterViewControllerTests.swift */; }; BA1416132CD360BCBA93D698 /* WebArchiveFileSytemManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC728E79E36A4CDD87F3078 /* WebArchiveFileSytemManager.swift */; }; BA957415E2E1A38A25550B99 /* MockIntroductoryPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 296A4AFE25C5E55DC5DD207D /* MockIntroductoryPeriod.swift */; }; + BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */; }; BADAD7DDF7A8F0460CBFF362 /* ButtonFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 643A346628DA026FEA092C27 /* ButtonFactory.swift */; }; BBC0ADE1AAB3E8C2DC5E4F01 /* ASN1Decoder+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37B17A8801A2A9454E66D892 /* ASN1Decoder+Utils.swift */; }; + BC30A871540635D2D9AF8C5D /* AccentColorRoundTripTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */; }; BC526F821C0BDAC76D7B3769 /* LocationAuthorizationStatusConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9CFF209DA6B8B42B405D20 /* LocationAuthorizationStatusConversionTests.swift */; }; BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7180900DD0767487E671639 /* AssignmentTests.swift */; }; BCD5EA74E59F7BC43B0816C5 /* TrackingManagerProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8E506778E9750512E9F9D3 /* TrackingManagerProxyTests.swift */; }; BCF808C7AC319C2B1F0AD52D /* ConfigResponseLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4827295A4E093CAEE2207DDF /* ConfigResponseLogicTests.swift */; }; BCFF20903199DDDE379D81E0 /* InAppReceiptPayloadContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 862888AB2869D09AA55A017D /* InAppReceiptPayloadContainer.swift */; }; BD152F3BA0BC197A5C6C8CC1 /* InAppReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0695B39826F85AACBA833B77 /* InAppReceipt.swift */; }; + BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */; }; BDBEE781EC4910025379F0B6 /* ASN1Serialization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588892D6DD1C4437CF507DE /* ASN1Serialization.swift */; }; BDECE549960DB9A5662939BE /* Tracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F6AFBC7C60A5074ACE8DF88 /* Tracking.swift */; }; BE5BE4ECDE6505182DD92AA1 /* PaywallViewControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88EBF6FC3090E004EE1377B4 /* PaywallViewControllerDelegate.swift */; }; @@ -426,10 +475,14 @@ C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */; }; C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */; }; C3897720526685D55A27C56C /* AttributionPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */; }; + C4081D22F2E243A72190B820 /* CustomerCenterSheetOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */; }; C570A889C4ADAA2C30E657CC /* EvaluateRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FD04064F8C3475007D5CBA /* EvaluateRules.swift */; }; + C576C1F4D9DF866BEE44477C /* PurchasePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */; }; C5A1C6E1DB61246348A88768 /* PaywallManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */; }; C5EA22647EFADC126DC4BFE8 /* Date+IsoString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2AF370C9EDF3C7A4605D385 /* Date+IsoString.swift */; }; C68EF5D7D3FD7E9FB2A95C47 /* Dictionary+Filter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1752442EC1C51EE4D01141AF /* Dictionary+Filter.swift */; }; + C6CC0FF052FE616DB8908757 /* CustomerCenterDependenciesMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */; }; + C71FC781059E1BE197CE9C38 /* AppVersionComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120D7D604E496BA935989AEA /* AppVersionComparator.swift */; }; C77A626D379969A86B900488 /* SWWebViewLoadingHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23886A83274F67B1DCB8573A /* SWWebViewLoadingHandlerTests.swift */; }; C7AB21123540550E513AD28A /* CoreDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D9CC1B947A08633E1C7BAE3 /* CoreDataManagerTests.swift */; }; C7E140466315324E9A1B9407 /* PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB7CF97D0926DCDAB133DB1 /* PermissionStatus.swift */; }; @@ -463,6 +516,8 @@ CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */; }; CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */; }; D0E19F665C7B230BF3FA122D /* TrackingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = F85ED994DEC92BB90ACC6AC2 /* TrackingResult.swift */; }; + D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */; }; + D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */; }; D1F8771E65157D1B0E05D0B9 /* ManifestDataFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2915A802FACB53B6094B011 /* ManifestDataFetcher.swift */; }; D25B3A24CEE42FC90BFA31D2 /* SuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E4096EBF0B8C9693322CD1 /* SuperwallEvent.swift */; }; D2E381B26362F434760F9AC0 /* GameControllerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */; }; @@ -485,9 +540,11 @@ D916475C6CE464EEB094F419 /* TaskRetryLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7867A3C9B173BC2D6000937 /* TaskRetryLogic.swift */; }; D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */; }; D978EAD4FA4865B5E07BF03B /* Future+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE36D141F461F6E945823FA /* Future+Async.swift */; }; + D99C565B5803B6ECE29A3D8B /* CustomerCenterEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */; }; DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */; }; DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */; }; DBF70D987418DD9EB504FBDE /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42956918D4FFA5FBA79F3AA5 /* Constants.swift */; }; + DC3ECD6BD248CCA5322CE05E /* CustomerCenterViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */; }; DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; }; DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */; }; DE62F8E261EC7C60FBAAAE1D /* BundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34468E3988E779132CE101A /* BundleHelper.swift */; }; @@ -505,6 +562,7 @@ E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */; }; E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */; }; E315F3C6BBCA8582BF540086 /* GetExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */; }; + E38FE7475E82AC6EA10F2C65 /* CustomerCenterViewModel+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */; }; E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D80A7C5B8A17B83C218656 /* MapSwiftErrors.swift */; }; E3EBCCD69E44711E26A4BFF3 /* SK2StoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71A62CA55C012D480DF37427 /* SK2StoreProduct.swift */; }; E3F2F347326B8D206D341FB6 /* TrackingLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95ED8690E8B88125776BC247 /* TrackingLogic.swift */; }; @@ -526,6 +584,7 @@ E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; }; EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; }; EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; }; + EAA404AC45EFECE0C8B58140 /* View+CustomerCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60CC773CBB97573AC2326D3 /* View+CustomerCenter.swift */; }; EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E522F5BCABB3A95B97549E /* PurchaseError.swift */; }; EB6540A8E1ECC3548C5E6368 /* PaywallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */; }; ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */; }; @@ -536,6 +595,7 @@ EDAEC46845C1DB11CB4C99AE /* SWConsoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */; }; EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */; }; F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */; }; + F09A77DEA87983ED5DE777EF /* DuplicateSubscriptionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47D400B1629D13D5BF38370B /* DuplicateSubscriptionBanner.swift */; }; F14330769F5384B9F4FD726E /* RestorationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF71CC25A340374B0A19295 /* RestorationResult.swift */; }; F15479C499547FE1B47DEA6B /* MockSkProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 719FE7C289CB0A621595A2A4 /* MockSkProduct.swift */; }; F2124CF2BE35ABD284EDCC45 /* PermissionsHandler+Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */; }; @@ -545,6 +605,7 @@ F326AADBFE0083F8F18E81CE /* Date+WithinAnHourBefore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */; }; F365F06BBA58055920FC751B /* SuperwallPlacementInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF73F40EFFEC58FA0D30DC4 /* SuperwallPlacementInfo.swift */; }; F3A7AF6D766960ECFE03E4B8 /* UIViewController+TopVc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759D7C0FCB370EB4FC33F4E4 /* UIViewController+TopVc.swift */; }; + F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */; }; F5CCDC90D8CBA5ED0C5BAD2E /* PublicGetPresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F676D3A0F5B540052D36B1 /* PublicGetPresentationResult.swift */; }; F5F8C2E02A057DA15C2936AB /* StorePresentationObjectsOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA9D233EACAB5090B0657D /* StorePresentationObjectsOperatorTests.swift */; }; F5FBA532DB79848B0537720F /* PresentationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94125DB9AC8A2EA66F983EDA /* PresentationResult.swift */; }; @@ -556,10 +617,12 @@ F7CDAF5068A17C1BFC254041 /* UIApplication+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E4503C39D6B4BFEB0FE624 /* UIApplication+Shared.swift */; }; F8E799A3A83A2758D6EAA385 /* Array+Guarded.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */; }; F958219E873CEF2A14079E22 /* GCControllerElement+buttonName.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */; }; + F96DB667FD1DE8B625089264 /* AppStoreVersionLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */; }; F99896A6ECBE1DCB67C40A5E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 503BCB840BC6A056A3289DAE /* Localizable.strings */; }; FA382AF6BA204F0B158B7175 /* TestModeEntitlementRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A60698DFEF03837D029E191 /* TestModeEntitlementRowView.swift */; }; FA677CF601A228D5B485FFDE /* PopupTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */; }; FA907E1BC8B68F238C791867 /* SuperwallDelegateAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */; }; + FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */; }; FAE2C990CFBD7485E4DBA8F5 /* PresentationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C825F0AD231C62462873E51A /* PresentationRequest.swift */; }; FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7611B89AB647CB1AB79CA912 /* RawExperiment.swift */; }; FC27E2B772AEEC425ED9944D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9EF9D5F77A002F6F0C03C77F /* PrivacyInfo.xcprivacy */; }; @@ -598,6 +661,7 @@ /* Begin PBXFileReference section */ 00736909A4B4A1C2F2C356BC /* Sk1StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sk1StoreProduct.swift; sourceTree = ""; }; 010F0F8FCE0A86D8F2823A47 /* PublicGameController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGameController.swift; sourceTree = ""; }; + 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionComparatorTests.swift; sourceTree = ""; }; 018F5856F39FC33AFE9740D4 /* ConfirmHoldoutAssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmHoldoutAssignmentTests.swift; sourceTree = ""; }; 019FA4010BA11D24C68B8544 /* FakeTrackingAuthorizationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingAuthorizationStatus.swift; sourceTree = ""; }; 01AC1F76564A6EC47EE696F9 /* DevicePreloadScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePreloadScriptTests.swift; sourceTree = ""; }; @@ -614,6 +678,7 @@ 072886BB8C0E08DF414D9162 /* InAppReceiptPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptPayload.swift; sourceTree = ""; }; 07FF7BCB3FA673AAEC8F9154 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 08AEAA8E3B5F51848523AE61 /* IntroOfferEligibilityRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferEligibilityRequest.swift; sourceTree = ""; }; + 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignReviewSnapshots.swift; sourceTree = ""; }; 0A716D8F8AA3CD7BBED04F4F /* TriggerAudienceOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerAudienceOccurrence.swift; sourceTree = ""; }; 0A9F09187825FB944A3BD8A9 /* DeepLinkRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouterTests.swift; sourceTree = ""; }; 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionPoster.swift; sourceTree = ""; }; @@ -633,11 +698,15 @@ 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMock.swift; sourceTree = ""; }; 115132479C9C41D57C9E3BA9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 120D7D604E496BA935989AEA /* AppVersionComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionComparator.swift; sourceTree = ""; }; 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainer.swift; sourceTree = ""; }; + 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterPushNavigator.swift; sourceTree = ""; }; 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestManager.swift; sourceTree = ""; }; 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; 15E6FBB3D0826827A04F87AE /* EndpointKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointKind.swift; sourceTree = ""; }; + 165B7C00C437146FB5C9DB92 /* CustomerCenterManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterManager.swift; sourceTree = ""; }; 16AC8D761A7F5A7F012EA39B /* EvaluateRulesOperatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluateRulesOperatorTests.swift; sourceTree = ""; }; + 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewModel.swift; sourceTree = ""; }; 1733444FB43D63E9DDF0D895 /* RedeemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedeemResponse.swift; sourceTree = ""; }; 1752442EC1C51EE4D01141AF /* Dictionary+Filter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Filter.swift"; sourceTree = ""; }; 182DFFCC0B7AAA4C67C4079D /* StoreProductDiscountType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductDiscountType.swift; sourceTree = ""; }; @@ -657,7 +726,10 @@ 1C16CBCBF2093DD9C5F3E105 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; 1CC92F1146FA9FA76AF25227 /* TrackingAuthorizationStatusConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingAuthorizationStatusConversionTests.swift; sourceTree = ""; }; 1D275ED98D2EE298F06708AF /* UIWindow+SwizzleSendEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+SwizzleSendEvent.swift"; sourceTree = ""; }; + 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateWarningView.swift; sourceTree = ""; }; 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementsResponse.swift; sourceTree = ""; }; + 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewController.swift; sourceTree = ""; }; + 1F86C1F3253E4C8FFBCB30AC /* CustomerCenterConfiguration+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterConfiguration+Appearance.swift"; sourceTree = ""; }; 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProductTests.swift; sourceTree = ""; }; 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfo.swift; sourceTree = ""; }; 20365697A9C396E8EC746B77 /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; @@ -695,19 +767,23 @@ 29E672B0703E6D85A3C65888 /* StoreTransactionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionType.swift; sourceTree = ""; }; 2A0325FB47A06456B909BCB5 /* TestModeRestoreDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeRestoreDrawer.swift; sourceTree = ""; }; 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePreloadScript.swift; sourceTree = ""; }; + 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterSheetOwnershipTests.swift; sourceTree = ""; }; 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionTransaction.swift; sourceTree = ""; }; 2B40F35B536F527B029D7BDE /* SWProductSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductSubscriptionPeriod.swift; sourceTree = ""; }; 2B42460730F1CDB856A35CFA /* Attribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attribution.swift; sourceTree = ""; }; 2B430DE1BA468E280567F03C /* ProductTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTemplate.swift; sourceTree = ""; }; 2BB10D9097CC124FFC34A4A0 /* RotationAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotationAnimation.swift; sourceTree = ""; }; 2BBA713121538238D5EBAB60 /* fr_CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr_CA; path = fr_CA.lproj/Localizable.strings; sourceTree = ""; }; + 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewSmokeTests.swift; sourceTree = ""; }; 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPeriodPriceTests.swift; sourceTree = ""; }; 2D025C31D5A64D577DF68095 /* TestModeDeviceAttributesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeDeviceAttributesViewController.swift; sourceTree = ""; }; 2D1A60826D12F97F96E671DF /* SpringAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpringAnimation.swift; sourceTree = ""; }; 2D1DB28BDC846324DD0CC091 /* PopupTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransitionLogic.swift; sourceTree = ""; }; 2D3DB70C19B7C07E1750DB8F /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; + 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterAction.swift; sourceTree = ""; }; 2DAE3B565ECB65BCCFD39A0A /* FileManagerMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerMigrator.swift; sourceTree = ""; }; 2E2027BFC214905CBE589AF2 /* KeypathWritable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeypathWritable.swift; sourceTree = ""; }; + 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterConfiguration.swift; sourceTree = ""; }; 2F6AFBC7C60A5074ACE8DF88 /* Tracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tracking.swift; sourceTree = ""; }; 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Superwall.swift; sourceTree = ""; }; 2FB3F2FC9FCCD4B912E61A1F /* FileManagerMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerMigratorTests.swift; sourceTree = ""; }; @@ -736,16 +812,19 @@ 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackableSuperwallEvent.swift; sourceTree = ""; }; 3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; }; 3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackMock.swift; sourceTree = ""; }; + 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasePresentationBuilderTests.swift; sourceTree = ""; }; 405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Notification.swift"; sourceTree = ""; }; 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityLogicTests.swift; sourceTree = ""; }; 42956918D4FFA5FBA79F3AA5 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 440ABDF6DAE2C15579B93DF1 /* PushTransitionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionDelegate.swift; sourceTree = ""; }; 45AFD6EE9BED296D075A9618 /* ASN1Templates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Templates.swift; sourceTree = ""; }; 45B3BC4249A9E9BA8E99EC7C /* CustomCallbackRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCallbackRegistry.swift; sourceTree = ""; }; + 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterPathResolverTests.swift; sourceTree = ""; }; 460B6F98BADD9EC96A978E40 /* SWProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProduct.swift; sourceTree = ""; }; 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLogic.swift; sourceTree = ""; }; 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadingDisabled.swift; sourceTree = ""; }; 4711FABAB250221629C47688 /* AppStoreProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProductTests.swift; sourceTree = ""; }; + 47D400B1629D13D5BF38370B /* DuplicateSubscriptionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuplicateSubscriptionBanner.swift; sourceTree = ""; }; 481D47E5121C521DDA268609 /* TriggerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRule.swift; sourceTree = ""; }; 4827295A4E093CAEE2207DDF /* ConfigResponseLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigResponseLogicTests.swift; sourceTree = ""; }; 498D6155C2B8B18E7F3D0E79 /* Validation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Validation.swift; sourceTree = ""; }; @@ -760,12 +839,17 @@ 4D7749FB975F9B2B5B156328 /* es_419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es_419; path = es_419.lproj/Localizable.strings; sourceTree = ""; }; 4D7EED1CCDE71C3CB5F87F84 /* PaywallPresentationStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyleTests.swift; sourceTree = ""; }; 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivalManifestDownloaded.swift; sourceTree = ""; }; + 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterSheets.swift; sourceTree = ""; }; 4EB1F47410CBC84D9ABD2F14 /* AutomaticPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseController.swift; sourceTree = ""; }; 4EC3DA8E774FBFE31F811FAF /* ConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManager.swift; sourceTree = ""; }; + 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportEmailComposerTests.swift; sourceTree = ""; }; 50458143450675EF205CE2C3 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; 51407421A3CBF7AF0FC76E60 /* Bundle+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Helpers.swift"; sourceTree = ""; }; 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedPropertyRequest.swift; sourceTree = ""; }; + 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasePresentation.swift; sourceTree = ""; }; 51786BD40838F00C9E495BA4 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; + 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterEnvironment.swift; sourceTree = ""; }; + 51B5BF7B93E59438467DB6C7 /* CustomerCenterScreenState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterScreenState.swift; sourceTree = ""; }; 5283BA49E380740C34D78856 /* OnDeviceCaching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDeviceCaching.swift; sourceTree = ""; }; 52E4503C39D6B4BFEB0FE624 /* UIApplication+Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Shared.swift"; sourceTree = ""; }; 532AB25EB4DA9BAA5E3FA530 /* OpacityAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpacityAnimation.swift; sourceTree = ""; }; @@ -785,10 +869,14 @@ 5836EFACFA00594CE8F9F377 /* Experiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experiment.swift; sourceTree = ""; }; 58466FF38687A9F8715F9B54 /* Array+Capability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Capability.swift"; sourceTree = ""; }; 58BA95995DE57E811FD65C02 /* PaywallCacheLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheLogicTests.swift; sourceTree = ""; }; + 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStringsTests.swift; sourceTree = ""; }; 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerTests.swift; sourceTree = ""; }; + 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewModelTests.swift; sourceTree = ""; }; 59C73BC10AC2F6DE8AB1074A /* SuperwallKit_AppleIncRootCertificate.cer */ = {isa = PBXFileReference; path = SuperwallKit_AppleIncRootCertificate.cer; sourceTree = ""; }; 5A413B6FF46B130D90A428B4 /* ProductPurchaserLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserLogic.swift; sourceTree = ""; }; + 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportEmailComposer.swift; sourceTree = ""; }; 5C2E30544869C5469AA31832 /* FactoryProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactoryProtocols.swift; sourceTree = ""; }; + 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreVersionLookup.swift; sourceTree = ""; }; 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerMock.swift; sourceTree = ""; }; 5CD130C74880AD07DCD2A7AA /* RedemptionResultObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedemptionResultObjc.swift; sourceTree = ""; }; 5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = ""; }; @@ -798,12 +886,15 @@ 60B80BEE0364C0EF86E2084E /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersion.swift; sourceTree = ""; }; 618BF4D10B7D87FAF8FB48CD /* ProductPurchaserSK1Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1Tests.swift; sourceTree = ""; }; + 618F0D37FD3B2213A296DEF4 /* Superwall+CustomerCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Superwall+CustomerCenter.swift"; sourceTree = ""; }; 61B5ABEC694245E0DC00E409 /* SurveyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyManager.swift; sourceTree = ""; }; 61D3EA7000250D02303BEF81 /* Date+WithinAnHourBefore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+WithinAnHourBefore.swift"; sourceTree = ""; }; 62AC69B94A568B7E14A391A8 /* SWProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductDiscount.swift; sourceTree = ""; }; 62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThrowableDecodable.swift; sourceTree = ""; }; + 632BA7AFDDD93F08252C9043 /* CustomerCenterDelegateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDelegateAdapter.swift; sourceTree = ""; }; 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandler.swift; sourceTree = ""; }; 63F4E993A2A86075BB6FB9FD /* SuperwallEventObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallEventObjc.swift; sourceTree = ""; }; + 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebProductPricingTests.swift; sourceTree = ""; }; 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; }; 64293B1D6F648DE113908AE7 /* EventsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsRequest.swift; sourceTree = ""; }; 643A346628DA026FEA092C27 /* ButtonFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonFactory.swift; sourceTree = ""; }; @@ -826,7 +917,9 @@ 69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = ""; }; 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1.swift; sourceTree = ""; }; 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegate.swift; sourceTree = ""; }; + 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsSection.swift; sourceTree = ""; }; 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTests.swift; sourceTree = ""; }; + 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterNavigator.swift; sourceTree = ""; }; 6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; }; 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExperiment.swift; sourceTree = ""; }; 6C47C3978AA22485EC9F5D24 /* SK2StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductDiscount.swift; sourceTree = ""; }; @@ -842,6 +935,7 @@ 70FC86C1189200C486627EAD /* ASN1Decoder+Unboxing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ASN1Decoder+Unboxing.swift"; sourceTree = ""; }; 7100728123E4690275724478 /* PresentPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentPaywall.swift; sourceTree = ""; }; 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductStore.swift; sourceTree = ""; }; + 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterConfiguration+ObjC.swift"; sourceTree = ""; }; 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestStoreUser.swift; sourceTree = ""; }; 719FE7C289CB0A621595A2A4 /* MockSkProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSkProduct.swift; sourceTree = ""; }; 71A62CA55C012D480DF37427 /* SK2StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProduct.swift; sourceTree = ""; }; @@ -849,6 +943,7 @@ 7318EF33D7374DC5C8B4549D /* SuperwallDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegate.swift; sourceTree = ""; }; 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitForSubsStatusAndConfig.swift; sourceTree = ""; }; 73BE8AD685B39ACAB331109C /* PurchaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseManager.swift; sourceTree = ""; }; + 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterView.swift; sourceTree = ""; }; 74DFC04FC0F3498D1EBE4B6A /* UIApplication+ActiveWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+ActiveWindow.swift"; sourceTree = ""; }; 750CC308DD48F4CE615DFC89 /* UIDevice+ModelName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+ModelName.swift"; sourceTree = ""; }; 7553D295A9E169B45FAC1477 /* FreeTrialTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeTrialTemplate.swift; sourceTree = ""; }; @@ -871,6 +966,8 @@ 7B1CE50799F517D3D52A1BB9 /* PostbackAssignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostbackAssignment.swift; sourceTree = ""; }; 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitedQueue.swift; sourceTree = ""; }; 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPInstallAttributionTests.swift; sourceTree = ""; }; + 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDelegate.swift; sourceTree = ""; }; + 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterViewModel+Support.swift"; sourceTree = ""; }; 7E27997BBCEAC330E4FB3718 /* pt_BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_BR; path = pt_BR.lproj/Localizable.strings; sourceTree = ""; }; 7FCE6A59348C9018F40D7AC5 /* LogScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScope.swift; sourceTree = ""; }; 7FE43B98D847BB6DE291F0B4 /* FakeTrackingAuthorizationStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingAuthorizationStatusTests.swift; sourceTree = ""; }; @@ -883,6 +980,7 @@ 81BE917F0AA7A7453B7D0BB2 /* AppSessionLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionLogicTests.swift; sourceTree = ""; }; 81C5A241FC9EF921D4E08FF1 /* ArchiveURLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveURLFetcher.swift; sourceTree = ""; }; 81D80A7C5B8A17B83C218656 /* MapSwiftErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapSwiftErrors.swift; sourceTree = ""; }; + 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterEventsTests.swift; sourceTree = ""; }; 82E6981E6A6574EE72B65A9E /* Paywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paywall.swift; sourceTree = ""; }; 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationInfoTests.swift; sourceTree = ""; }; 83416F0F1B5294C350D5CF70 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; @@ -918,9 +1016,11 @@ 8F0D2AB91DA66490A73D1CB5 /* PostbackAssignmentWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostbackAssignmentWrapper.swift; sourceTree = ""; }; 8F17CFCD6B3B96A609A5B870 /* PaddleProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddleProduct.swift; sourceTree = ""; }; 8FC7F8602B38644BCCDAD159 /* ConfirmPaywallAssignmentOperatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmPaywallAssignmentOperatorTests.swift; sourceTree = ""; }; + 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreUpdateCheckTests.swift; sourceTree = ""; }; 910786130E2D7EDE2ED5452D /* StoreKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitManager.swift; sourceTree = ""; }; 911CD5859EC1BE7E428F06C4 /* EvaluationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluationResult.swift; sourceTree = ""; }; 91B1FD7EAF0ACE1983E07F69 /* Superwall_Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Superwall_Assets.xcassets; sourceTree = ""; }; + 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDependencies.swift; sourceTree = ""; }; 92001AC11F099F7B03AF338A /* SuperwallKitTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SuperwallKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 92A6B82F855E19B9C180C659 /* ConfigManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerTests.swift; sourceTree = ""; }; 933A87E03A62F412CC6B150C /* TransactionProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionProduct.swift; sourceTree = ""; }; @@ -935,19 +1035,26 @@ 95ED8690E8B88125776BC247 /* TrackingLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingLogic.swift; sourceTree = ""; }; 95F0D7536DD55DC78654443C /* ArchivingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivingError.swift; sourceTree = ""; }; 96237542E710511C51A39070 /* PurchaseResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseResult.swift; sourceTree = ""; }; + 96A6D71D51DB3F1BCE3E167B /* CustomerCenterViewModel+UpdateBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterViewModel+UpdateBanner.swift"; sourceTree = ""; }; 96BEA0A81E531D4B82F9EEE7 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; 97A579F56E5CEF54DB9E9B62 /* DarkBlurredBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarkBlurredBackground.swift; sourceTree = ""; }; 97D7F499B2CBFFF0A61F8D72 /* ConfigLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigLogicTests.swift; sourceTree = ""; }; + 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasePresentationBuilder.swift; sourceTree = ""; }; + 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathsListView.swift; sourceTree = ""; }; 9845F441ACFCBC267E3368C5 /* String+ROT13.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+ROT13.swift"; sourceTree = ""; }; 988E0E3F8D992744C9AC196F /* PermissionStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionStatusTests.swift; sourceTree = ""; }; 990461F7A9B2F3ED62B3A628 /* PaywallViewControllerDelegateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerDelegateAdapter.swift; sourceTree = ""; }; 99DE58E263F7AE44DDB6BD52 /* SK1StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1StoreProductDiscount.swift; sourceTree = ""; }; 9A7FFEA64AF7F4E09F052FCD /* Error+SafeLocalizedDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+SafeLocalizedDescription.swift"; sourceTree = ""; }; + 9B079AAB038F2DE800E71AD8 /* FeedbackSurveyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackSurveyView.swift; sourceTree = ""; }; 9B75209DF76859131941CA0F /* Variables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Variables.swift; sourceTree = ""; }; 9BD0FF16D93BEDE46E250E3B /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebView.swift; sourceTree = ""; }; + 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterStrings+English.swift"; sourceTree = ""; }; 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1ReceiptManager.swift; sourceTree = ""; }; + 9C515E4FDA405CBFADFA9FAB /* RestoreOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreOverlay.swift; sourceTree = ""; }; 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; + 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterActionTests.swift; sourceTree = ""; }; 9DC4D23D1EDDA249C928930D /* PaddingListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingListener.swift; sourceTree = ""; }; 9E1EFE389B54C304F2B01620 /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 9E3DAD767490972EA30257F9 /* EntitlementProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementProcessorTests.swift; sourceTree = ""; }; @@ -966,6 +1073,7 @@ A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; }; A2D40088A465E104CF5C67CC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; A3781CF21200CD2333F6779A /* GetPaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallManager.swift; sourceTree = ""; }; + A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseCardView.swift; sourceTree = ""; }; A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationIdTests.swift; sourceTree = ""; }; A3F4F74393061C17CEB18F90 /* ManagedEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedEventData.swift; sourceTree = ""; }; A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Guarded.swift"; sourceTree = ""; }; @@ -974,7 +1082,9 @@ A5110E43405C69969E9DA67B /* PublicGetPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPaywall.swift; sourceTree = ""; }; A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurchaseResult+Internal.swift"; sourceTree = ""; }; A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSubscriptionPeriod.swift; sourceTree = ""; }; + A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDelegateAdapterTests.swift; sourceTree = ""; }; A6B47DD5F59411CC529CD2DB /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; + A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterConfigurationTests.swift; sourceTree = ""; }; A78C5C57C3C92444EBAC2E38 /* TrackingManagerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingManagerProxy.swift; sourceTree = ""; }; A79E9DBDDA7FEE63C15FBEAF /* CELEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CELEvaluatorTests.swift; sourceTree = ""; }; A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = ""; }; @@ -1001,7 +1111,9 @@ AFB9AEAF72391341B4BDF6CD /* GetPaywallVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallVC.swift; sourceTree = ""; }; B002FEEF20120D3A6B2AE923 /* SurveyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyManagerTests.swift; sourceTree = ""; }; B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductCyclesTests.swift; sourceTree = ""; }; + B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagementScreenView.swift; sourceTree = ""; }; B0E817399EBAAB14C51A1DCB /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterViewModel+PurchaseDetail.swift"; sourceTree = ""; }; B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerFactory.swift; sourceTree = ""; }; B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallOptions.swift; sourceTree = ""; }; B1DF64C51FA26B40D8D7B880 /* ScaleAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaleAnimation.swift; sourceTree = ""; }; @@ -1016,8 +1128,10 @@ B48AAFA27917F0BE3ADC6FFB /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; B52A0EFBBFE9D2F949EA4C28 /* UserAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAttributes.swift; sourceTree = ""; }; B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawPaywallResponse.swift; sourceTree = ""; }; + B60CC773CBB97573AC2326D3 /* View+CustomerCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+CustomerCenter.swift"; sourceTree = ""; }; B634347011742D475E3F1A27 /* ConfigLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigLogic.swift; sourceTree = ""; }; B6EB705DC16CB1AC24B75BA7 /* pt_PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_PT; path = pt_PT.lproj/Localizable.strings; sourceTree = ""; }; + B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDependenciesMocks.swift; sourceTree = ""; }; B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestBody.swift; sourceTree = ""; }; B6FD04064F8C3475007D5CBA /* EvaluateRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EvaluateRules.swift; sourceTree = ""; }; B7180900DD0767487E671639 /* AssignmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentTests.swift; sourceTree = ""; }; @@ -1026,7 +1140,9 @@ B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalPresentationLogic.swift; sourceTree = ""; }; B88E86C67F934540D846B8BA /* EmptyResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyResponse.swift; sourceTree = ""; }; B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPMatchResponseTests.swift; sourceTree = ""; }; + B8F5F084F94D853AA5B5CC79 /* StoreKitTransactionLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitTransactionLookup.swift; sourceTree = ""; }; B9553EC1E394EF7AE8788291 /* InAppReceiptAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppReceiptAttribute.swift; sourceTree = ""; }; + B95FB737271FE6289A0A4BD1 /* NoPurchasesScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoPurchasesScreenView.swift; sourceTree = ""; }; BA4EC02056512C9F677CC345 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; BA9100DDAD2E8596F96A1BCB /* Assignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assignment.swift; sourceTree = ""; }; BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelper.swift; sourceTree = ""; }; @@ -1056,6 +1172,7 @@ C22CA9431D5F791BE7A9BE27 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; }; C2300AFFC31667E749E85EAC /* TrackingLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingLogicTests.swift; sourceTree = ""; }; C2489DE003DCA646B562A200 /* APIStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIStoreProduct.swift; sourceTree = ""; }; + C262ABB80CCC8266542F26C5 /* WebSubscriptionPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSubscriptionPathTests.swift; sourceTree = ""; }; C273F16E6EDA8803DE9DA47D /* SK2TransactionListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2TransactionListener.swift; sourceTree = ""; }; C29D14ADD3228FF784BF2435 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; C2AF370C9EDF3C7A4605D385 /* Date+IsoString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsoString.swift"; sourceTree = ""; }; @@ -1068,6 +1185,7 @@ C41A0BC4F44E339BBCA0D9F0 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; C4961C52D6005392183A0277 /* LocationManagerProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerProxyTests.swift; sourceTree = ""; }; C567965E23812D8C25869C19 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogueCacheTests.swift; sourceTree = ""; }; C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceFormatterProvider.swift; sourceTree = ""; }; C65CEB049E29538C699F6EF8 /* PaywallPresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationInfo.swift; sourceTree = ""; }; C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreProduct.swift; sourceTree = ""; }; @@ -1092,6 +1210,7 @@ CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedTriggerRuleOccurrence.swift; sourceTree = ""; }; CD8C0C8DA633BE856F5B9EEF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; CD9298A79020030E9A1357A6 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathTitleTests.swift; sourceTree = ""; }; CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallOptions.swift; sourceTree = ""; }; D00CE1D40C874F73A3BEC090 /* PresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationInfo.swift; sourceTree = ""; }; D054D402A5F820BB3D18D8AC /* StoreProductAdapterObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductAdapterObjc.swift; sourceTree = ""; }; @@ -1130,11 +1249,14 @@ DD7F90791145963999DDA319 /* TaskCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCoalescer.swift; sourceTree = ""; }; DEF0596D5BDDE0911046E60D /* IntroOfferToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferToken.swift; sourceTree = ""; }; DEFDA9310B29A0C918D7A292 /* EventsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsResponse.swift; sourceTree = ""; }; + DF4B8468A75C8948EEFC6D4E /* StoreKitTransactionLookupMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitTransactionLookupMock.swift; sourceTree = ""; }; DFD2580D6C95C96CC3051BCB /* PermissionTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionTypeTests.swift; sourceTree = ""; }; DFE7B1045C0541E66A965FC1 /* IARError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IARError.swift; sourceTree = ""; }; E09C238ADC0B019047FAB1DF /* JSONToDict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONToDict.swift; sourceTree = ""; }; E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementsStatus.swift; sourceTree = ""; }; + E19BDEFD1BAB331A814E95CE /* CustomerCenterManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterManagerTests.swift; sourceTree = ""; }; E1C8B2F4853060258BC2CBD9 /* VerificationResult+Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerificationResult+Transaction.swift"; sourceTree = ""; }; + E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseDetailActionsTests.swift; sourceTree = ""; }; E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GCControllerElement+buttonName.swift"; sourceTree = ""; }; E23F2FE294EBC63F81786A85 /* PresentationItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationItems.swift; sourceTree = ""; }; E2915A802FACB53B6094B011 /* ManifestDataFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestDataFetcher.swift; sourceTree = ""; }; @@ -1169,14 +1291,17 @@ F16AFE9C93A441CFB6A95F10 /* String+CamelCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CamelCase.swift"; sourceTree = ""; }; F2A2A54314BAEAF65B46D322 /* NetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTests.swift; sourceTree = ""; }; F2F3523491EC638DBBBD2133 /* AutomaticPurchaseControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseControllerTests.swift; sourceTree = ""; }; + F30029EE3419EE5BD4CD2948 /* CustomerCenterViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterViewControllerTests.swift; sourceTree = ""; }; F338AF233A9EF2A20B1AC5A5 /* MockPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPurchaseController.swift; sourceTree = ""; }; F34468E3988E779132CE101A /* BundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleHelper.swift; sourceTree = ""; }; F36CB341B28F250F5252A8DF /* Transaction+LatestSince.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Transaction+LatestSince.swift"; sourceTree = ""; }; F4300EBF7463A42D2FB89371 /* ArchiveRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveRequest.swift; sourceTree = ""; }; F49804DCB74FEEFA0D3438A9 /* ASN1Decoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1Decoder.swift; sourceTree = ""; }; F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+mergeChanges.swift"; sourceTree = ""; }; + F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColorRoundTripTests.swift; sourceTree = ""; }; F57F454704875FFFC5CE1827 /* InternalGetPresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalGetPresentationResult.swift; sourceTree = ""; }; F5A959F1F550446C980DC5E5 /* StoreProductType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductType.swift; sourceTree = ""; }; + F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterPathResolver.swift; sourceTree = ""; }; F636156244B674A56ADC461C /* UIView+SpringAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+SpringAnimation.swift"; sourceTree = ""; }; F67A5C0CA15AF645709A2545 /* PurchaseSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSource.swift; sourceTree = ""; }; F6EED7C7E264C38A1A7C3EFB /* StorePayment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePayment.swift; sourceTree = ""; }; @@ -1189,6 +1314,7 @@ F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRetryingTests.swift; sourceTree = ""; }; F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionAttempts.swift; sourceTree = ""; }; FA3A82C80F89023672D56AD7 /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; + FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDependenciesTests.swift; sourceTree = ""; }; FB28BCE1EE94BFE935B984AB /* DeviceHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperTests.swift; sourceTree = ""; }; FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWLocalizationViewController.swift; sourceTree = ""; }; FC52CA0CE82A5605AFF7A075 /* ExperimentTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentTemplate.swift; sourceTree = ""; }; @@ -1219,6 +1345,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 035954DCF2214A634651A352 /* UIKit */ = { + isa = PBXGroup; + children = ( + 632BA7AFDDD93F08252C9043 /* CustomerCenterDelegateAdapter.swift */, + 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */, + 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */, + ); + path = UIKit; + sourceTree = ""; + }; 068A223BBB54F9127201504F /* Paywall */ = { isa = PBXGroup; children = ( @@ -1260,6 +1396,18 @@ path = "Custom URL Session"; sourceTree = ""; }; + 0885E36F54C6369D2E5FCDC7 /* Views */ = { + isa = PBXGroup; + children = ( + F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */, + 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */, + 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, + 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */, + CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */, + ); + path = Views; + sourceTree = ""; + }; 0D4F1B49114819E9E6923508 /* Product Fetching */ = { isa = PBXGroup; children = ( @@ -1342,6 +1490,27 @@ path = Purchasing; sourceTree = ""; }; + 1422D4F63A53E2768C2E90E6 /* Views */ = { + isa = PBXGroup; + children = ( + 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */, + 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */, + 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */, + 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */, + 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */, + 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */, + 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */, + 47D400B1629D13D5BF38370B /* DuplicateSubscriptionBanner.swift */, + 9B079AAB038F2DE800E71AD8 /* FeedbackSurveyView.swift */, + B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */, + B95FB737271FE6289A0A4BD1 /* NoPurchasesScreenView.swift */, + 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */, + A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */, + 9C515E4FDA405CBFADFA9FAB /* RestoreOverlay.swift */, + ); + path = Views; + sourceTree = ""; + }; 14F8842196EB2B1F82B3E024 /* Network */ = { isa = PBXGroup; children = ( @@ -1760,6 +1929,22 @@ path = "Web View"; sourceTree = ""; }; + 3EFE894723C00D72A3C01061 /* CustomerCenter */ = { + isa = PBXGroup; + children = ( + 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, + E19BDEFD1BAB331A814E95CE /* CustomerCenterManagerTests.swift */, + 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */, + 9723663065538DB5CF16F4A4 /* Actions */, + 4664D61C9B4C8ADC2B834E36 /* Logic */, + E40538D195AAE4E177C98959 /* Models */, + FCAA9EC0D71CB6AE1EBEB75A /* UIKit */, + B5DA90160501C06A71BE97C5 /* ViewModel */, + 0885E36F54C6369D2E5FCDC7 /* Views */, + ); + path = CustomerCenter; + sourceTree = ""; + }; 41C20E3AA2F12F64126C7D72 /* StoreTransaction */ = { isa = PBXGroup; children = ( @@ -1853,6 +2038,21 @@ path = Location; sourceTree = ""; }; + 4664D61C9B4C8ADC2B834E36 /* Logic */ = { + isa = PBXGroup; + children = ( + 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */, + 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */, + 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, + 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, + 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, + 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */, + C262ABB80CCC8266542F26C5 /* WebSubscriptionPathTests.swift */, + ); + path = Logic; + sourceTree = ""; + }; 46CEF77B52399F8A27F8452F /* Events */ = { isa = PBXGroup; children = ( @@ -1883,6 +2083,14 @@ path = "Push Transition"; sourceTree = ""; }; + 4AC7FD1A50349966FF78DB51 /* Actions */ = { + isa = PBXGroup; + children = ( + B8F5F084F94D853AA5B5CC79 /* StoreKitTransactionLookup.swift */, + ); + path = Actions; + sourceTree = ""; + }; 4D7656D6A565958F58A644AF /* Misc */ = { isa = PBXGroup; children = ( @@ -2006,6 +2214,18 @@ path = Operators; sourceTree = ""; }; + 5E4DEFC8C051825F0007162E /* Logic */ = { + isa = PBXGroup; + children = ( + 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */, + 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, + F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */, + 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, + 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, + ); + path = Logic; + sourceTree = ""; + }; 61A371A2B16AE3626D64EA20 /* Presentation State */ = { isa = PBXGroup; children = ( @@ -2126,6 +2346,18 @@ path = Alert; sourceTree = ""; }; + 6CA03A908C710F4F27075427 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, + 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */, + B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */, + 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */, + 96A6D71D51DB3F1BCE3E167B /* CustomerCenterViewModel+UpdateBanner.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; 6F9276EC956CE4A6C09949CE /* Delegates */ = { isa = PBXGroup; children = ( @@ -2214,6 +2446,14 @@ path = Internal; sourceTree = ""; }; + 797427D95711FB08D8A372E2 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + B60CC773CBB97573AC2326D3 /* View+CustomerCenter.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 79E3E1B6A224AE8B4085CF9B /* Templating */ = { isa = PBXGroup; children = ( @@ -2273,8 +2513,10 @@ 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */, C22CA9431D5F791BE7A9BE27 /* Documentation.docc */, 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */, + 618F0D37FD3B2213A296DEF4 /* Superwall+CustomerCenter.swift */, 33C89C06ECF942287FA14087 /* Analytics */, 91B8F43244A7C30402275032 /* Config */, + E4455CBE23BD58AF980439B4 /* CustomerCenter */, 97F6AA52B81B82F72AB80D7C /* Debug */, 9C21AAF80FD220C960FE568F /* Delegate */, A34416D82C5BDBAA82A119C1 /* Dependencies */, @@ -2421,6 +2663,14 @@ path = Logic; sourceTree = ""; }; + 942120C628E5C7B6E603287A /* Delegate */ = { + isa = PBXGroup; + children = ( + 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */, + ); + path = Delegate; + sourceTree = ""; + }; 961CAF7F687CC7D8CEDB40F3 /* Notifications */ = { isa = PBXGroup; children = ( @@ -2440,6 +2690,14 @@ path = Contacts; sourceTree = ""; }; + 9723663065538DB5CF16F4A4 /* Actions */ = { + isa = PBXGroup; + children = ( + DF4B8468A75C8948EEFC6D4E /* StoreKitTransactionLookupMock.swift */, + ); + path = Actions; + sourceTree = ""; + }; 97F6AA52B81B82F72AB80D7C /* Debug */ = { isa = PBXGroup; children = ( @@ -2573,6 +2831,19 @@ path = Logic; sourceTree = ""; }; + AC076DCADFAF818A0325BA18 /* Models */ = { + isa = PBXGroup; + children = ( + 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */, + 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, + 1F86C1F3253E4C8FFBCB30AC /* CustomerCenterConfiguration+Appearance.swift */, + 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */, + 51B5BF7B93E59438467DB6C7 /* CustomerCenterScreenState.swift */, + 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */, + ); + path = Models; + sourceTree = ""; + }; AC10C98F9F6D03AD79376ADE /* Capabilities */ = { isa = PBXGroup; children = ( @@ -2670,6 +2941,17 @@ path = "Custom URL Session"; sourceTree = ""; }; + B5DA90160501C06A71BE97C5 /* ViewModel */ = { + isa = PBXGroup; + children = ( + B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */, + FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */, + 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */, + E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; B95C41E4499A61EDED234DEF /* Migration */ = { isa = PBXGroup; children = ( @@ -2714,6 +2996,7 @@ FE0E783785F917188D12F4B4 /* Utils.swift */, E338A5AE4BB82E592AE9B9BA /* Analytics */, D554340BB6652F5FA1F21FF8 /* Config */, + 3EFE894723C00D72A3C01061 /* CustomerCenter */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, 373AFF230833A951B6E5DF36 /* Identity */, @@ -2822,8 +3105,8 @@ children = ( 0E3AC3B23DAAA8C1D125BDD3 /* CoreDataManager.swift */, 50458143450675EF205CE2C3 /* CoreDataStack.swift */, - EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, 2DAF3427CF469F5373C2BFD7 /* Managed Models */, + EC51351CA716C5C3B71E2FA1 /* SuperwallKit_Model.xcdatamodeld */, ); path = "Core Data"; sourceTree = ""; @@ -2956,6 +3239,31 @@ path = "Receipt Manager"; sourceTree = ""; }; + E40538D195AAE4E177C98959 /* Models */ = { + isa = PBXGroup; + children = ( + 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */, + A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */, + ); + path = Models; + sourceTree = ""; + }; + E4455CBE23BD58AF980439B4 /* CustomerCenter */ = { + isa = PBXGroup; + children = ( + 165B7C00C437146FB5C9DB92 /* CustomerCenterManager.swift */, + 4AC7FD1A50349966FF78DB51 /* Actions */, + 942120C628E5C7B6E603287A /* Delegate */, + 5E4DEFC8C051825F0007162E /* Logic */, + AC076DCADFAF818A0325BA18 /* Models */, + 797427D95711FB08D8A372E2 /* SwiftUI */, + 035954DCF2214A634651A352 /* UIKit */, + 6CA03A908C710F4F27075427 /* ViewModel */, + 1422D4F63A53E2768C2E90E6 /* Views */, + ); + path = CustomerCenter; + sourceTree = ""; + }; E4C1D2384C6CAD193D3CE652 /* Templating */ = { isa = PBXGroup; children = ( @@ -3101,6 +3409,15 @@ path = "Receipt Models"; sourceTree = ""; }; + FCAA9EC0D71CB6AE1EBEB75A /* UIKit */ = { + isa = PBXGroup; + children = ( + A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */, + F30029EE3419EE5BD4CD2948 /* CustomerCenterViewControllerTests.swift */, + ); + path = UIKit; + sourceTree = ""; + }; FD8F67EFF69ECDBE85EB24F5 /* Message Handling */ = { isa = PBXGroup; children = ( @@ -3173,9 +3490,10 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1430; + TargetAttributes = { + }; }; buildConfigurationList = B7BB212B66F694F1FDA2FA4F /* Build configuration list for PBXProject "SuperwallKit" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -3228,6 +3546,7 @@ 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); preferredProjectObjectVersion = 77; + productRefGroup = 778C04FFAA9840C37CA3C1CA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -3257,11 +3576,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + BC30A871540635D2D9AF8C5D /* AccentColorRoundTripTests.swift in Sources */, C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */, 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */, 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */, A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, + 0F6EB7DF5B8373B4718D00B9 /* AppStoreUpdateCheckTests.swift in Sources */, + 5B254755EE51075D28EA9282 /* AppVersionComparatorTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, @@ -3269,6 +3591,7 @@ B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, E984458E465D5A834CC52302 /* CacheMock.swift in Sources */, CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */, + 85F77E1A644AC7049D341D31 /* CatalogueCacheTests.swift in Sources */, 2A07A8F4A55E28D13777D03E /* CheckDebuggerPresentationOperatorTests.swift in Sources */, A73497BB3DCD881318A5CD86 /* ConfigLogicTests.swift in Sources */, 63CED0C62636A9FD03B7315A /* ConfigManagerMock.swift in Sources */, @@ -3285,10 +3608,24 @@ B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */, 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */, 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, + 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */, + D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, + 3F203A8C5B6DD47D33B6C516 /* CustomerCenterDelegateAdapterTests.swift in Sources */, + C6CC0FF052FE616DB8908757 /* CustomerCenterDependenciesMocks.swift in Sources */, + 5D1F0BE78AFD0801B6073B4A /* CustomerCenterDependenciesTests.swift in Sources */, + 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, + 26081D80FCF7BCD475103467 /* CustomerCenterManagerTests.swift in Sources */, + F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, + C4081D22F2E243A72190B820 /* CustomerCenterSheetOwnershipTests.swift in Sources */, + BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, + BA0F56BF5C028624554EEC89 /* CustomerCenterViewControllerTests.swift in Sources */, + DC3ECD6BD248CCA5322CE05E /* CustomerCenterViewModelTests.swift in Sources */, + 959F8F9F86BD7E770D842FE3 /* CustomerCenterViewSmokeTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, + 8E04DDF7FA8D6A76E96378F3 /* DesignReviewSnapshots.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, @@ -3331,6 +3668,7 @@ F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */, A191F045B8A9EE2D4A3B757D /* OccurrenceLogicTests.swift in Sources */, 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */, + 0A6711CD579DFCD3F0E4F263 /* PathTitleTests.swift in Sources */, 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */, 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */, 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */, @@ -3353,6 +3691,8 @@ 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, + 24D4B587A3D2FF168587117F /* PurchaseDetailActionsTests.swift in Sources */, + D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */, @@ -3367,11 +3707,13 @@ 919A08D7F25BD2DF27A22697 /* StorageMock.swift in Sources */, 713A1F9D9861C6A1E5EB9174 /* StorageTests.swift in Sources */, 701B1B586B6C1E3F0B3AF560 /* StoreKitManagerTests.swift in Sources */, + 684EE8F6BFE518BA5952E3B5 /* StoreKitTransactionLookupMock.swift in Sources */, F5F8C2E02A057DA15C2936AB /* StorePresentationObjectsOperatorTests.swift in Sources */, B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */, 5E51E14716E29C9B88B8A6F2 /* StripeTrialEligibilityTests.swift in Sources */, 097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */, E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */, + ADFFF22341B34F9F28FE595B /* SupportEmailComposerTests.swift in Sources */, 89CC491C60F7CD12D3E73284 /* SurveyManagerTests.swift in Sources */, 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */, 18D39CB7BCF324B44197735D /* TaskRetryingTests.swift in Sources */, @@ -3386,6 +3728,8 @@ 8B200F99B71D706D45948339 /* Utils.swift in Sources */, 6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */, 37264FFAF68B8349BD6F9BE8 /* WebEntitlementRedeemerTests.swift in Sources */, + 82060DEBCCB16E69508D249D /* WebProductPricingTests.swift in Sources */, + 4A270686A4C804CDC85FB5B8 /* WebSubscriptionPathTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3407,6 +3751,7 @@ BDBEE781EC4910025379F0B6 /* ASN1Serialization.swift in Sources */, E95C8C0485512D77E37703C5 /* ASN1Templates.swift in Sources */, 2C7867867DDAD83E5856ECC9 /* ASN1Types.swift in Sources */, + AC13B2D29FF61BDC995132DD /* AccountDetailsSection.swift in Sources */, 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */, AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */, ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */, @@ -3416,6 +3761,9 @@ 995FD66283C7B03D3B33DF89 /* AppSessionLogic.swift in Sources */, E986B0CF98B8C09AAA961E94 /* AppSessionManager.swift in Sources */, 5DE5CE789559545FF1A8AD12 /* AppStoreProduct.swift in Sources */, + F96DB667FD1DE8B625089264 /* AppStoreVersionLookup.swift in Sources */, + 32A52161B29C999F06217B9A /* AppUpdateWarningView.swift in Sources */, + C71FC781059E1BE197CE9C38 /* AppVersionComparator.swift in Sources */, 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */, F61541FC6670E0667A96FE44 /* ArchiveManifest.swift in Sources */, FCF3B638D0D802202113DCBD /* ArchiveManifestUsage.swift in Sources */, @@ -3465,6 +3813,27 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */, D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */, 9E21D97817B1BA97806283B3 /* CustomURLSession.swift in Sources */, + B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */, + 5578870EF7D736E46CC8E828 /* CustomerCenterConfiguration+Appearance.swift in Sources */, + BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, + 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, + 481903391564D2B19A9BD285 /* CustomerCenterDelegate.swift in Sources */, + 781A9E4339F865190C8A5D6A /* CustomerCenterDelegateAdapter.swift in Sources */, + A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */, + D99C565B5803B6ECE29A3D8B /* CustomerCenterEnvironment.swift in Sources */, + 295EF01B171923E20329DF91 /* CustomerCenterManager.swift in Sources */, + 4616C021DCB8228B75D8B591 /* CustomerCenterNavigator.swift in Sources */, + 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, + 30C0236F4E6A5260BFEF59AF /* CustomerCenterPushNavigator.swift in Sources */, + 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */, + 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */, + 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, + FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, + 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */, + B02D152D88E005E08078DA51 /* CustomerCenterViewModel+PurchaseDetail.swift in Sources */, + E38FE7475E82AC6EA10F2C65 /* CustomerCenterViewModel+Support.swift in Sources */, + 7C56FE8873F3E1E57905BC91 /* CustomerCenterViewModel+UpdateBanner.swift in Sources */, + 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, C5EA22647EFADC126DC4BFE8 /* Date+IsoString.swift in Sources */, @@ -3485,6 +3854,7 @@ 507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */, DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */, 61AE58F17230AE1578B9FB19 /* Documentation.docc in Sources */, + F09A77DEA87983ED5DE777EF /* DuplicateSubscriptionBanner.swift in Sources */, B89435087910E6B501471622 /* Email.swift in Sources */, 9EAE577E60052F5E1C7B9657 /* EmptyResponse.swift in Sources */, CB1E11FB74879A29DD1C9EB1 /* Encodable+Dictionary.swift in Sources */, @@ -3513,6 +3883,7 @@ E9D95044254D79D2439D7B3E /* FakeTrackingAuthorizationStatus.swift in Sources */, E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */, ED1C693657DA7FCBAE2DDDC6 /* FeatureGatingBehaviour.swift in Sources */, + 93102FF82C63E6A2C5C6EBB9 /* FeedbackSurveyView.swift in Sources */, 767974DF68CE67AE2066E3D6 /* FileManagerMigrator.swift in Sources */, 0A5EFFC920E6BB29814BD66B /* Foundation+ASN1Coder.swift in Sources */, 12D25E5674DF81B033C9659E /* FreeTrialTemplate.swift in Sources */, @@ -3575,10 +3946,12 @@ 69FCCCDFF58E4F625489F17E /* MMPAttributionManager.swift in Sources */, A1EE1654484802E9E08CC32E /* ManagedEventData.swift in Sources */, 234C1753A4606242CA765CA7 /* ManagedTriggerRuleOccurrence.swift in Sources */, + 7CB32020EFC0785659ADA76C /* ManagementScreenView.swift in Sources */, D1F8771E65157D1B0E05D0B9 /* ManifestDataFetcher.swift in Sources */, E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */, 07862D18809FA5DEA95AE440 /* NSManagedObjectContext+mergeChanges.swift in Sources */, 2698874EEAE37BAECE7B8FD8 /* Network.swift in Sources */, + A50C9EFDE9ED8778BB4C44D3 /* NoPurchasesScreenView.swift in Sources */, 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */, 753FBF77D03B954DCE963A52 /* NotificationProtocols.swift in Sources */, 0B5A0C6EA2D1C98B32110FD9 /* NotificationScheduler.swift in Sources */, @@ -3589,6 +3962,7 @@ 2231B31B4B9A25778069B20A /* PaddleProduct.swift in Sources */, 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */, 2D4E15921C454AC9B9C13709 /* PassableValue.swift in Sources */, + 2F74D47847F140EFCAF1C814 /* PathsListView.swift in Sources */, F605AA51AB24B564D3A21B07 /* Paywall.swift in Sources */, A9F9A35AEC72D17C7C15DAD4 /* PaywallArchiveManager.swift in Sources */, BFCA9FE6639175011D0369D9 /* PaywallCacheLogic.swift in Sources */, @@ -3663,11 +4037,14 @@ 0AB9CCC164DD87C81318AAB0 /* PublicIdentity.swift in Sources */, 8F24AAC773F119481E2C654F /* PublicPresentation.swift in Sources */, D66461863D54A56BE9C29310 /* Publisher+Async.swift in Sources */, + 664F2F91821AC7E9E80756CF /* PurchaseCardView.swift in Sources */, 4E078EFFD0C1992563021220 /* PurchaseController.swift in Sources */, 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */, AC0AF760E7EA2FFF5621955D /* PurchaseControllerObjcAdapter.swift in Sources */, EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */, 070DFAAB357CE1D547E946E1 /* PurchaseManager.swift in Sources */, + C576C1F4D9DF866BEE44477C /* PurchasePresentation.swift in Sources */, + 3C9432E5304D2C50E5B2B06F /* PurchasePresentationBuilder.swift in Sources */, B146C134ABE092C3C9ACADEC /* PurchaseResult+Internal.swift in Sources */, 03EBC531CDC26957534DE46A /* PurchaseResult.swift in Sources */, D506526569FAA54E3220A02A /* PurchaseSource.swift in Sources */, @@ -3687,6 +4064,7 @@ 40E6B7996E9BA4B5D65F1432 /* RedemptionResult.swift in Sources */, CE20B453845974EC28C9D39D /* RedemptionResultObjc.swift in Sources */, F14330769F5384B9F4FD726E /* RestorationResult.swift in Sources */, + A028BE1961AF337DD06104F6 /* RestoreOverlay.swift in Sources */, C80ACD3C05345709DAB248FD /* RestoreType.swift in Sources */, 76984544B2AEED280390BFB0 /* RotationAnimation.swift in Sources */, 79E35504745555BC5CA14360 /* SK1ReceiptManager.swift in Sources */, @@ -3716,6 +4094,7 @@ 7E355FE2557391CE4593B9B0 /* SpringAnimation.swift in Sources */, FFC1A413FF8B96275C4C1649 /* Storage.swift in Sources */, C053DEA1266E78107F828B19 /* StoreKitManager.swift in Sources */, + 4ADD216B493FF99AE4404438 /* StoreKitTransactionLookup.swift in Sources */, F2D5ADFFC1DD842744E0160A /* StorePayment.swift in Sources */, 9532DC347593689DCDDBA1A4 /* StorePresentationObjects.swift in Sources */, D7F5A91A1E37E6BFB84E5609 /* StoreProduct.swift in Sources */, @@ -3737,6 +4116,7 @@ B60C3A3AD25CE2E2C513A2D2 /* Stubbable.swift in Sources */, C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */, B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */, + 28A1E7AFF222A9C36F4AE019 /* Superwall+CustomerCenter.swift in Sources */, CFEB0D797815E8EDFB059767 /* Superwall.swift in Sources */, 17C0F1960CD89B6BFA8B2FBB /* SuperwallDelegate.swift in Sources */, FA907E1BC8B68F238C791867 /* SuperwallDelegateAdapter.swift in Sources */, @@ -3747,6 +4127,7 @@ 941F2296F5250A15DE6B5B70 /* SuperwallKit_Model.xcdatamodeld in Sources */, 0A1366F15DD3C1761C095DF5 /* SuperwallOptions.swift in Sources */, F365F06BBA58055920FC751B /* SuperwallPlacementInfo.swift in Sources */, + 94209E030EB310AAE5450272 /* SupportEmailComposer.swift in Sources */, 776B122691BB67A703BB0DDD /* Survey.swift in Sources */, C23744AAAF31A533693281B6 /* SurveyManager.swift in Sources */, 210BEE229900A19606803EDC /* SurveyOption.swift in Sources */, @@ -3812,6 +4193,7 @@ 5C504112376B6E0798CA20CE /* Variables.swift in Sources */, DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */, 9A0D436A679DD6FC72BEBE9A /* VerificationResult+Transaction.swift in Sources */, + EAA404AC45EFECE0C8B58140 /* View+CustomerCenter.swift in Sources */, C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */, D89F615A7826947C9246F6B1 /* WebArchive.swift in Sources */, BA1416132CD360BCBA93D698 /* WebArchiveFileSytemManager.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd61..4d1752fd7c 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "YES"> + + + + + + + + + + + + = [] + + func latestTransactionID(for productId: String) async -> UInt64? { + transactionIDs[productId] + } + + func isFamilyShared(productId: String) async -> Bool { + familyShared.contains(productId) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift new file mode 100644 index 0000000000..3fc0497100 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift @@ -0,0 +1,50 @@ +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenter events") +struct CustomerCenterEventsTests { + @Test("descriptions and objc mirrors") + func descriptions() { + #expect(SuperwallEvent.customerCenterOpen(screen: "management").description == "customerCenter_open") + #expect(SuperwallEvent.customerCenterClose.description == "customerCenter_close") + #expect(SuperwallEvent.customerCenterAction(action: .restore, pathId: "p", productId: nil).description == "customerCenter_action") + #expect(SuperwallEvent.customerCenterSurveyResponse(surveyId: "s", optionId: "o", action: .manageSubscription, pathId: "p", productId: "x").description == "customerCenter_surveyResponse") + #expect(SuperwallEvent.customerCenterRefundRequest(productId: "x", status: .success).description == "customerCenter_refundRequest") + #expect(SuperwallEventObjc(event: .customerCenterClose) == .customerCenterClose) + } + + @Test("open event carries the screen and how the Customer Center was presented") + func openParameters() async { + let sheet = InternalSuperwallEvent.CustomerCenterOpen(screen: "management", presentation: "sheet") + let sheetParams = await sheet.getSuperwallParameters() + #expect(sheetParams["screen"] as? String == "management") + #expect(sheetParams["presentation"] as? String == "sheet") + + let embedded = InternalSuperwallEvent.CustomerCenterOpen(screen: "no_purchases", presentation: "embedded") + let embeddedParams = await embedded.getSuperwallParameters() + #expect(embeddedParams["screen"] as? String == "no_purchases") + #expect(embeddedParams["presentation"] as? String == "embedded") + } + + @Test("trackable parameters") + func parameters() async { + let action = InternalSuperwallEvent.CustomerCenterAction(action: .custom(identifier: "del"), pathId: "p1", productId: "prod") + let params = await action.getSuperwallParameters() + #expect(params["action"] as? String == "custom") + #expect(params["custom_identifier"] as? String == "del") + #expect(params["path_id"] as? String == "p1") + #expect(params["product_id"] as? String == "prod") + + let survey = InternalSuperwallEvent.CustomerCenterSurveyResponse(surveyId: "s", optionId: "o", action: .manageSubscription, pathId: "p", productId: nil) + let sp = await survey.getSuperwallParameters() + #expect(sp["survey_id"] as? String == "s") + #expect(sp["option_id"] as? String == "o") + #expect(sp["action"] as? String == "manage_subscription") + #expect(sp["product_id"] == nil) + + let refund = InternalSuperwallEvent.CustomerCenterRefundRequest(productId: "x", status: .userCancelled) + let rp = await refund.getSuperwallParameters() + #expect(rp["status"] as? String == "user_cancelled") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift new file mode 100644 index 0000000000..cac691439d --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift @@ -0,0 +1,147 @@ +// +// CustomerCenterManagerTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +import UIKit +@testable import SuperwallKit + +@Suite("CustomerCenterManager", .serialized) +@MainActor +struct CustomerCenterManagerTests { + @available(iOS 15.0, *) + @Test("resolveConfiguration: override > options > default") + func resolution() { + let container = DependencyContainer() + let manager = CustomerCenterManager(container: container) + #expect(manager.resolveConfiguration(nil) == container.configManager.options.customerCenter) + let custom = CustomerCenterConfiguration.default + custom.support.email = "x@y.z" + #expect(manager.resolveConfiguration(custom) === custom) + } + + @available(iOS 15.0, *) + @Test("second present while presented is ignored") + func singleInstance() { + let container = DependencyContainer() + let manager = CustomerCenterManager(container: container) + let presenter = UIViewController() + let window = makeTestWindow(rootViewController: presenter) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { presenter.viewIfLoaded?.window != nil } + + manager.present(configuration: nil, from: presenter, delegate: nil, onDismiss: nil) + #expect(manager.isPresented) + manager.present(configuration: nil, from: presenter, delegate: nil, onDismiss: nil) + #expect(manager.presentCount == 1) + + window.isHidden = true + } + + @available(iOS 15.0, *) + @Test("retains the delegate while presented, releases it after dismiss") + func retainsDelegateForPresentationDuration() { + final class ProbeDelegate: CustomerCenterDelegate {} + + let container = DependencyContainer() + let manager = CustomerCenterManager(container: container) + let presenter = UIViewController() + let window = makeTestWindow(rootViewController: presenter) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { presenter.viewIfLoaded?.window != nil } + // Non-animated so that, in a host where UIKit does drive transitions to completion, this stays + // fast and doesn't depend on animation timing. + manager.presentsAnimated = false + + var strongDelegate: ProbeDelegate? = ProbeDelegate() + weak var weakDelegate = strongDelegate + + manager.present(configuration: nil, from: presenter, delegate: strongDelegate, onDismiss: nil) + strongDelegate = nil + + // Still presented: the manager should be the only thing keeping the delegate alive. + #expect(weakDelegate != nil) + #expect(manager.isPresented) + + // A hostless test target's run loop never drives a real view-controller-transition animation to + // completion (there's no live display link committing frames): UIKit registers the presentation + // synchronously but never finishes loading the presented view into a window, so it never calls + // back into `viewDidDisappear` on its own. Trigger the exact same cleanup closure `present` + // wires up as `onDismiss` — the real production code that clears `retainedDelegate` — the way + // UIKit would if the transition had completed. + manager.presentedControllerForTesting?.onDismiss?() + spinRunLoop(timeout: 1) { weakDelegate == nil } + + #expect(weakDelegate == nil) + #expect(!manager.isPresented) + + // With nothing presented, `dismiss(completion:)`'s early-exit path should complete synchronously. + var dismissed = false + manager.dismiss { dismissed = true } + #expect(dismissed) + + window.isHidden = true + } + + @available(iOS 15.0, *) + @Test("viewDidDisappear fires didDismiss while the delegate is still retained") + func viewDidDisappearFiresDelegateBeforeRelease() { + final class ProbeDelegate: CustomerCenterDelegate { + let onDidDismiss: () -> Void + init(onDidDismiss: @escaping () -> Void) { self.onDidDismiss = onDidDismiss } + func customerCenterDidDismiss() { onDidDismiss() } + } + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + let viewModel = CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + var didDismissCount = 0 + var delegate: ProbeDelegate? = ProbeDelegate { didDismissCount += 1 } + let adapter = CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil) + let controller = CustomerCenterViewController(viewModel: viewModel, adapter: adapter, presentationStyle: .modal) + // The manager's `onDismiss` releases its retained delegate — the only strong reference here. + // The view model's dismissal (default 0.6s debounce) must not be what delivers `didDismiss`, + // or it would reach a released delegate. + controller.onDismiss = { delegate = nil } + + // The teardown check deliberately no longer treats "no presenter" on its own as a dismissal, + // because that is equally true of every pushed controller for its whole lifetime. A hostless + // test target never completes a modal transition, so UIKit never records that the controller + // was presented — set that one fact the way `viewDidAppear` would have, and let the real check + // run against it. + controller.wasPresentedModally = true + controller.viewDidDisappear(false) + + #expect(didDismissCount == 1) + #expect(delegate == nil) + } + + /// A window backed by a real connected `UIWindowScene` when one is available (as it is when a + /// unit test target runs inside its generated host app), since modal presentation/dismissal + /// transitions need one to actually animate and complete. Falls back to a legacy frame-based + /// window when no scene is connected. + private func makeTestWindow(rootViewController: UIViewController) -> UIWindow { + let window: UIWindow + if let scene = UIApplication.sharedApplication?.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: scene) + window.frame = scene.screen.bounds + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + window.rootViewController = rootViewController + return window + } + + /// Spins the main run loop in short increments until `condition` is true or `timeout` elapses, + /// so tests can wait deterministically on UIKit's asynchronous presentation/dismissal animations. + private func spinRunLoop(timeout: TimeInterval, until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterStringsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterStringsTests.swift new file mode 100644 index 0000000000..dc3c850870 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterStringsTests.swift @@ -0,0 +1,52 @@ +// +// CustomerCenterStringsTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterStrings") +struct CustomerCenterStringsTests { + init() { + if !Superwall.isInitialized { + Superwall.configure(apiKey: "test") + } + } + + @Test("english dictionary covers every key in en.lproj") + func englishCoversBundle() throws { + let bundle = LocalizationLogic.localizedBundle(Locale(identifier: "en")) + let path = try #require(bundle.path(forResource: "Localizable", ofType: "strings")) + let dict = try #require(NSDictionary(contentsOfFile: path) as? [String: String]) + let ccKeys = dict.keys.filter { $0.hasPrefix("customer_center_") } + #expect(!ccKeys.isEmpty) + for key in ccKeys { + #expect(englishStrings[key] == dict[key], "mismatch for \(key)") + } + } + + @Test("bundled lookup formats arguments and falls back to english then key") + func bundledLookup() { + let strings = CustomerCenterStrings.bundled(locale: Locale(identifier: "en")) + #expect(strings.string("customer_center_renews_on", "Jan 1") == "Renews on Jan 1") + #expect(strings.string("customer_center_not_a_key") == "customer_center_not_a_key") + } + + @Test("every lproj contains every customer_center key") + func allLocalesComplete() throws { + let enBundle = LocalizationLogic.localizedBundle(Locale(identifier: "en")) + let enPath = try #require(enBundle.path(forResource: "Localizable", ofType: "strings")) + let enKeys = Set((NSDictionary(contentsOfFile: enPath) as? [String: String] ?? [:]).keys.filter { $0.hasPrefix("customer_center_") }) + for localization in Bundle.module.localizations where localization != "Base" { + guard + let path = Bundle.module.path(forResource: localization, ofType: "lproj").flatMap(Bundle.init(path:))?.path(forResource: "Localizable", ofType: "strings"), + let dict = NSDictionary(contentsOfFile: path) as? [String: String] + else { continue } + #expect(enKeys.isSubset(of: Set(dict.keys)), "\(localization) is missing Customer Center keys") + } + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift new file mode 100644 index 0000000000..6896fe66ed --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift @@ -0,0 +1,364 @@ +// +// AppStoreUpdateCheckTests.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +/// `.serialized` because `StubURLProtocol` below is the only way to stand in for the network here, +/// and `URLSession` instantiates protocol classes itself — so its configuration and its record of +/// what was requested have to be static. Run in parallel, the cases overwrite each other's stub +/// responses and share a request count, which is how they passed locally and failed on CI. +@Suite("App Store update check", .serialized) +@MainActor +struct AppStoreUpdateCheckTests { + private func makeViewModel( + installed: String, + isSandbox: Bool = false, + configuredLatest: String? = nil, + checksAppStore: Bool = true, + shouldWarn: Bool = true, + appStoreVersion: String? = nil + ) -> (CustomerCenterViewModel, AppStoreVersionProviderMock) { + let provider = AppStoreVersionProviderMock(version: appStoreVersion) + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []), + environment: EnvironmentMock(appVersion: installed, isSandbox: isSandbox), + appStoreVersion: provider + ) + let configuration = CustomerCenterConfiguration.default + configuration.support.latestAppVersion = configuredLatest + configuration.support.checksAppStoreForUpdates = checksAppStore + configuration.support.shouldWarnToUpdate = shouldWarn + let viewModel = CustomerCenterViewModel( + configuration: configuration, + dependencies: deps, + strings: .english + ) + return (viewModel, provider) + } + + // MARK: - The lookup drives the banner + + @available(iOS 15.0, *) + @Test("shows the banner when the App Store is ahead of the installed build") + func showsBannerWhenStoreIsAhead() async { + let (viewModel, provider) = makeViewModel(installed: "1.4.0", appStoreVersion: "1.5.0") + await viewModel.load() + #expect(provider.callCount == 1) + #expect(viewModel.showsUpdateBanner) + } + + @available(iOS 15.0, *) + @Test("stays hidden when the installed build matches the App Store") + func hiddenWhenUpToDate() async { + let (viewModel, _) = makeViewModel(installed: "1.5.0", appStoreVersion: "1.5.0") + await viewModel.load() + #expect(!viewModel.showsUpdateBanner) + } + + /// The case that rules out an `installed != latest` comparison: a build ahead of the store is + /// normal for testers, and telling them to "update" would send them backwards. + @available(iOS 15.0, *) + @Test("stays hidden when the installed build is ahead of the App Store") + func hiddenWhenAheadOfStore() async { + let (viewModel, _) = makeViewModel(installed: "2.0.0", appStoreVersion: "1.9.3") + await viewModel.load() + #expect(!viewModel.showsUpdateBanner) + } + + /// Calendar versioning is still a monotonically increasing numeric tuple, so ordered comparison + /// works on it exactly as it does on semantic versions. + @available(iOS 15.0, *) + @Test("orders calendar versions correctly", arguments: [ + ("2026.2.9", "2026.3.1", true), + ("2026.3.1", "2026.2.9", false), + ("2025.12.0", "2026.1.0", true) + ]) + func ordersCalendarVersions(installed: String, store: String, expected: Bool) async { + let (viewModel, _) = makeViewModel(installed: installed, appStoreVersion: store) + await viewModel.load() + #expect(viewModel.showsUpdateBanner == expected) + } + + // MARK: - When the lookup must not run + + @available(iOS 15.0, *) + @Test("never looks the version up on TestFlight, sandbox or simulator builds") + func skipsLookupInSandbox() async { + let (viewModel, provider) = makeViewModel( + installed: "1.4.0", + isSandbox: true, + appStoreVersion: "1.5.0" + ) + await viewModel.load() + #expect(provider.callCount == 0, "a sandbox build must not reach the network") + #expect(!viewModel.showsUpdateBanner) + } + + @available(iOS 15.0, *) + @Test("a configured version wins and suppresses the lookup") + func configuredVersionWins() async { + let (viewModel, provider) = makeViewModel( + installed: "1.4.0", + configuredLatest: "1.4.0", + appStoreVersion: "9.9.9" + ) + await viewModel.load() + #expect(provider.callCount == 0) + #expect(!viewModel.showsUpdateBanner, "the configured version says we're current") + } + + @available(iOS 15.0, *) + @Test("opting out skips the lookup") + func optOutSkipsLookup() async { + let (viewModel, provider) = makeViewModel( + installed: "1.4.0", + checksAppStore: false, + appStoreVersion: "1.5.0" + ) + await viewModel.load() + #expect(provider.callCount == 0) + #expect(!viewModel.showsUpdateBanner) + } + + @available(iOS 15.0, *) + @Test("shouldWarnToUpdate off skips the lookup entirely") + func warningOffSkipsLookup() async { + let (viewModel, provider) = makeViewModel( + installed: "1.4.0", + shouldWarn: false, + appStoreVersion: "1.5.0" + ) + await viewModel.load() + #expect(provider.callCount == 0) + #expect(!viewModel.showsUpdateBanner) + } + + @available(iOS 15.0, *) + @Test("a failed lookup hides the banner rather than guessing") + func failedLookupHidesBanner() async { + let (viewModel, provider) = makeViewModel(installed: "1.4.0", appStoreVersion: nil) + await viewModel.load() + #expect(provider.callCount == 1) + #expect(!viewModel.showsUpdateBanner) + } + + @available(iOS 15.0, *) + @Test("the lookup runs once per presentation, not once per reload") + func lookupIsNotRepeated() async { + let (viewModel, provider) = makeViewModel(installed: "1.4.0", appStoreVersion: "1.5.0") + await viewModel.load() + await viewModel.load() + #expect(provider.callCount == 1) + } + + // MARK: - Response parsing + + @Test("reads the version out of a lookup response") + func parsesLookupResponse() throws { + let json = #"{"resultCount":1,"results":[{"version":"3.2.1","trackName":"Acme"}]}"# + #expect(AppStoreVersionLookup.parseVersion(from: Data(json.utf8)) == "3.2.1") + } + + @Test("treats an empty result set as no answer", arguments: [ + #"{"resultCount":0,"results":[]}"#, + #"{"results":[{"trackName":"Acme"}]}"#, + #"{"results":[{"version":""}]}"#, + "not json at all" + ]) + func parsesUnusableResponses(json: String) { + #expect(AppStoreVersionLookup.parseVersion(from: Data(json.utf8)) == nil) + } + + + // MARK: - The lookup itself + + /// Everything above this point stops at `parseVersion`, so the network round trip, the 24h + /// cache and the region query item all shipped unexercised — despite `defaults`, `session` and + /// `now` existing on the initializer as seams for exactly this. A `URLProtocol` stub and a + /// throwaway suite of defaults cover them without touching the network. + private final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var status = 200 + nonisolated(unsafe) static var body = Data() + nonisolated(unsafe) static var requestedURLs: [URL] = [] + + static func reset(status: Int = 200, json: String = #"{"results":[{"version":"3.2.1"}]}"#) { + self.status = status + self.body = Data(json.utf8) + self.requestedURLs = [] + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if let url = request.url { + Self.requestedURLs.append(url) + } + let response = HTTPURLResponse( + url: request.url ?? URL(fileURLWithPath: "/"), + statusCode: Self.status, + httpVersion: nil, + headerFields: nil + ) + // swiftlint:disable:next force_unwrapping + client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + } + + private func makeStubbedSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeDefaults() throws -> UserDefaults { + let name = "customer-center-tests-\(UUID().uuidString)" + return try #require(UserDefaults(suiteName: name)) + } + + @Test("scopes the lookup to the bundle id and region") + func lookupSendsBundleIdAndRegion() async throws { + StubURLProtocol.reset() + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "GB", + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + #expect(await lookup.latestAppStoreVersion() == "3.2.1") + + let url = try #require(StubURLProtocol.requestedURLs.first) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.contains(URLQueryItem(name: "bundleId", value: "com.acme.app"))) + #expect(items.contains(URLQueryItem(name: "country", value: "GB"))) + } + + /// A device with no region set must still get a lookup, rather than one scoped to an empty + /// country the endpoint would reject. + @Test("omits the region when there isn't one", arguments: [nil, ""]) + func lookupOmitsAnEmptyRegion(region: String?) async throws { + StubURLProtocol.reset() + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: region, + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + _ = await lookup.latestAppStoreVersion() + + let url = try #require(StubURLProtocol.requestedURLs.first) + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(!items.contains { $0.name == "country" }) + } + + @Test("a non-2xx response is not an answer", arguments: [404, 429, 500]) + func lookupIgnoresErrorResponses(status: Int) async throws { + StubURLProtocol.reset(status: status) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + #expect(await lookup.latestAppStoreVersion() == nil) + } + + @Test("a second check inside 24 hours is answered from the cache") + func lookupCachesWithinTheDay() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + var clock = Date(timeIntervalSince1970: 1_000_000) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: defaults, + session: session, + now: { clock } + ) + + #expect(await lookup.latestAppStoreVersion() == "3.2.1") + #expect(StubURLProtocol.requestedURLs.count == 1) + + // A day minus a minute later, and the answer on the wire has changed. The cache must win. + clock = clock.addingTimeInterval(AppStoreVersionLookup.cacheDuration - 60) + StubURLProtocol.body = Data(#"{"results":[{"version":"9.9.9"}]}"#.utf8) + #expect(await lookup.latestAppStoreVersion() == "3.2.1", "still inside the cache window") + #expect(StubURLProtocol.requestedURLs.count == 1, "and no second request was made") + } + + /// The cache holds one answer, and the answer is region-specific: a version that exists in one + /// store may not exist in another. Keying on time alone served the previous region's answer for + /// the rest of the day — precisely when it is most likely to be wrong. + @Test("changing region is a cache miss") + func lookupDoesNotServeAnotherRegionsAnswer() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + let clock = Date(timeIntervalSince1970: 1_000_000) + + let inGB = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "GB", + defaults: defaults, + session: session, + now: { clock } + ) + #expect(await inGB.latestAppStoreVersion() == "3.2.1") + + StubURLProtocol.body = Data(#"{"results":[{"version":"1.0.0"}]}"#.utf8) + let inJP = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "JP", + defaults: defaults, + session: session, + now: { clock } + ) + + #expect(await inJP.latestAppStoreVersion() == "1.0.0", "same second, different store") + #expect(StubURLProtocol.requestedURLs.count == 2) + } + + @Test("the cache expires after 24 hours") + func lookupRefetchesAfterTheDay() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + var clock = Date(timeIntervalSince1970: 1_000_000) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: defaults, + session: session, + now: { clock } + ) + + _ = await lookup.latestAppStoreVersion() + clock = clock.addingTimeInterval(AppStoreVersionLookup.cacheDuration + 1) + StubURLProtocol.body = Data(#"{"results":[{"version":"9.9.9"}]}"#.utf8) + + #expect(await lookup.latestAppStoreVersion() == "9.9.9") + #expect(StubURLProtocol.requestedURLs.count == 2) + } + + // MARK: - Configuration round trip + + @Test("configuration written before the flag existed still decodes") + func decodesLegacyConfiguration() throws { + let json = #"{"email":"help@acme.com","shouldWarnToUpdate":true}"# + let support = try JSONDecoder().decode( + CustomerCenterConfiguration.Support.self, + from: Data(json.utf8) + ) + #expect(support.email == "help@acme.com") + #expect(support.checksAppStoreForUpdates, "absent flag should default to on") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift new file mode 100644 index 0000000000..7e7a692a57 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift @@ -0,0 +1,22 @@ +// +// AppVersionComparatorTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +@testable import SuperwallKit + +@Suite("AppVersionComparator") +struct AppVersionComparatorTests { + @Test(arguments: [ + ("1.0.0" as String?, "1.0.1" as String?, true), ("1.0.0" as String?, "1.1" as String?, true), ("1.9.9" as String?, "2" as String?, true), + ("2.0.0" as String?, "1.9.9" as String?, false), ("1.2.3" as String?, "1.2.3" as String?, false), ("1.2" as String?, "1.2.0" as String?, false), + ("1.2.3.4" as String?, "1.2.3" as String?, false), ("1.2.3" as String?, "1.2.3.9" as String?, false), // 4th component ignored + ("abc" as String?, "1.0.0" as String?, false), ("1.0.0" as String?, "abc" as String?, false), (nil as String?, "1.0.0" as String?, false), ("1.0.0" as String?, nil as String?, false) + ]) + func compare(installed: String?, latest: String?, expected: Bool) { + #expect(AppVersionComparator.isInstalledVersion(installed, olderThan: latest) == expected) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift new file mode 100644 index 0000000000..7c8da534c2 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift @@ -0,0 +1,94 @@ +// +// CatalogueCacheTests.swift +// +// +// Created by Jordan Morgan on 09/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Catalogue cache") +struct CatalogueCacheTests { + private func response() -> SuperwallProductsResponse { + let json = #"{"data":[],"object":"list","has_more":false}"# + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(SuperwallProductsResponse.self, from: Data(json.utf8)) + } + + /// The reason this exists: `apply(customerInfo:refetchProducts:)` runs on load, on every restore + /// and after every sheet dismissal, and all four call sites refetch. + @available(iOS 15.0, *) + @Test("a second visit inside the window doesn't refetch") + func cachesWithinTheWindow() async throws { + var clock = Date(timeIntervalSince1970: 1_000_000) + let cache = CatalogueCache { clock } + var fetches = 0 + + _ = try await cache.products { fetches += 1; return response() } + clock = clock.addingTimeInterval(CatalogueCache.ttl - 1) + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 1) + } + + @available(iOS 15.0, *) + @Test("the cache expires, so a price edit shows up") + func expiresAfterTheWindow() async throws { + var clock = Date(timeIntervalSince1970: 1_000_000) + let cache = CatalogueCache { clock } + var fetches = 0 + + _ = try await cache.products { fetches += 1; return response() } + clock = clock.addingTimeInterval(CatalogueCache.ttl + 1) + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 2) + } + + /// A failed load must not be remembered: the cards render without prices and the next `apply` + /// should try again, rather than serving the failure for five minutes. + @available(iOS 15.0, *) + @Test("a failure isn't cached") + func doesNotCacheFailures() async throws { + struct Boom: Error {} + let cache = CatalogueCache() + var fetches = 0 + + await #expect(throws: Boom.self) { + _ = try await cache.products { fetches += 1; throw Boom() } + } + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 2) + } + + /// An actor is reentrant across `await`. Without an in-flight handoff, two callers landing on a + /// cold cache both pass the freshness check and both fetch — on the Customer Center that is + /// `load()` and the customer-info publisher calling `apply` at nearly the same moment. + @available(iOS 15.0, *) + @Test("callers overlapping on a cold cache share one fetch") + func overlappingCallersShareOneFetch() async throws { + let cache = CatalogueCache() + let fetches = Counter() + + async let first = cache.products { + await fetches.increment() + try await Task.sleep(nanoseconds: 150_000_000) + return self.response() + } + async let second = cache.products { + await fetches.increment() + return self.response() + } + _ = try await (first, second) + + #expect(await fetches.value == 1) + } +} + +private actor Counter { + var value = 0 + func increment() { value += 1 } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift new file mode 100644 index 0000000000..53cab19d31 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift @@ -0,0 +1,126 @@ +// +// CustomerCenterPathResolverTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterPathResolver") +struct CustomerCenterPathResolverTests { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let paths = CustomerCenterConfiguration.default.managementScreen.paths + let monthly = ProductDisplayInfo(productId: "monthly", title: "Monthly", localizedPrice: "$9.99", price: 9.99, + localizedPeriod: "month", subscriptionGroupId: "g1", isAutoRenewable: true) + + func presentation(_ sub: SubscriptionTransaction, product: ProductDisplayInfo?) -> PurchasePresentation { + PurchasePresentationBuilder(now: { now }, strings: .english) + .build(customerInfo: CustomerInfo(subscriptions: [sub], nonSubscriptions: [], entitlements: []), + products: product.map { [$0.productId: $0] } ?? [:])[0] + } + func sub(active: Bool = true, willRenew: Bool = true, expires: TimeInterval? = 86_400, revoked: Bool = false, + offer: LatestSubscription.OfferType? = nil, store: ProductStore = .appStore, group: String? = "g1", + purchasedAgo: TimeInterval = 86_400) -> SubscriptionTransaction { + SubscriptionTransaction(transactionId: "t", productId: "monthly", purchaseDate: now.addingTimeInterval(-purchasedAgo), + willRenew: willRenew, isRevoked: revoked, isInGracePeriod: false, isInBillingRetryPeriod: false, isActive: active, + expirationDate: expires.map { now.addingTimeInterval($0) }, offerType: offer, subscriptionGroupId: group, store: store) + } + func context(_ purchase: PurchasePresentation?, product: ProductDisplayInfo? = nil, family: Bool = false, email: Bool = true, + web: URL? = nil, changePlan: Bool = true, canOpen: Bool = true, isScreenLevel: Bool = false) -> PathResolutionContext { + PathResolutionContext(purchase: purchase, product: product, isFamilyShared: family, supportEmailAvailable: email, + webManagementURL: web, isChangePlanSheetAvailable: changePlan, canOpenURLs: canOpen, + isScreenLevel: isScreenLevel, now: now) + } + func destinations(_ ctx: PathResolutionContext, _ paths: [CustomerCenterConfiguration.Path]? = nil) -> [ResolvedPathDestination] { + CustomerCenterPathResolver.resolve(paths ?? self.paths, context: ctx).map(\.destination) + } + + @Test("screen level (no purchase): restore, contactSupport, url, custom only") + func screenLevel() { + var p = paths + p.append(.init(id: "faq", type: .url(URL(string: "https://a.b")!, title: "FAQ", openMethod: .inApp))) + p.append(.init(id: "c", type: .custom(identifier: "x"))) + #expect(destinations(context(nil), p) == [.restore, .contactSupport, .url(URL(string: "https://a.b")!, inApp: true), .custom("x")]) + } + + @Test("active App Store sub with product: all default paths") + func activeAppStore() { + let ctx = context(presentation(sub(), product: monthly), product: monthly) + #expect(destinations(ctx) == [.changePlan(groupId: "g1", productIds: nil), .refund(productId: "monthly"), + .appleManageSheet(subscriptionGroupId: "g1"), .contactSupport]) + } + + @Test("restore hidden at purchase level; contactSupport hidden without email") + func restoreAndEmail() { + let ctx = context(presentation(sub(), product: monthly), product: monthly, email: false) + #expect(!destinations(ctx).contains(.restore)) + #expect(!destinations(ctx).contains(.contactSupport)) + } + + @Test("restore stays available at screen level with a purchase, and is hidden once drilled in") + func restoreIsScreenLevelOnly() { + // The management screen's single-purchase layout passes its purchase so the other paths can + // resolve, but restore must still be offered there — a user with one subscription may well + // have other purchases to restore. Only the drilled-in detail screen hides it. + let purchase = presentation(sub(), product: monthly) + let screenLevel = destinations(context(purchase, product: monthly, isScreenLevel: true)) + #expect(screenLevel.contains(.restore)) + #expect(screenLevel.first == .restore) + + let drilledIn = destinations(context(purchase, product: monthly, isScreenLevel: false)) + #expect(!drilledIn.contains(.restore)) + + // Screen level adds restore and changes nothing else. + #expect(screenLevel.filter { $0 != .restore } == drilledIn) + } + + @Test("cancelled sub: no manage sheet; expired: no manage/change; revoked: no refund/manage/change") + func stateGating() { + #expect(!destinations(context(presentation(sub(willRenew: false), product: monthly), product: monthly)).contains(.appleManageSheet(subscriptionGroupId: "g1"))) + let expired = destinations(context(presentation(sub(active: false, expires: -5), product: monthly), product: monthly)) + #expect(expired == [.refund(productId: "monthly"), .contactSupport]) // expired keeps refund (Apple allows), loses manage/change + let revoked = destinations(context(presentation(sub(revoked: true), product: monthly), product: monthly)) + #expect(revoked == [.contactSupport]) + } + + @Test("refund: hidden for trial, $0 price, revoked, outside window; shown inside window") + func refundGating() { + #expect(!destinations(context(presentation(sub(offer: .trial), product: monthly), product: monthly)).contains(.refund(productId: "monthly"))) + let free = ProductDisplayInfo(productId: "monthly", title: "M", localizedPrice: "$0.00", price: 0, localizedPeriod: nil, subscriptionGroupId: "g1", isAutoRenewable: true) + #expect(!destinations(context(presentation(sub(), product: free), product: free)).contains(.refund(productId: "monthly"))) + #expect(!destinations(context(presentation(sub(revoked: true), product: monthly), product: monthly)).contains(.refund(productId: "monthly"))) + let windowed = [CustomerCenterConfiguration.Path(id: "r", type: .refund(window: 3600))] + #expect(destinations(context(presentation(sub(purchasedAgo: 7200), product: monthly), product: monthly), windowed).isEmpty) + #expect(destinations(context(presentation(sub(purchasedAgo: 60), product: monthly), product: monthly), windowed) == [.refund(productId: "monthly")]) + } + + @Test("changePlan: curated ids, hidden when sheet unavailable, hidden without group") + func changePlan() { + let curated = [CustomerCenterConfiguration.Path(id: "c", type: .changePlan(productIds: ["a", "b"]))] + #expect(destinations(context(presentation(sub(), product: monthly), product: monthly), curated) == [.changePlan(groupId: "g1", productIds: ["a", "b"])]) + #expect(destinations(context(presentation(sub(), product: monthly), product: monthly, changePlan: false), curated).isEmpty) + let noGroup = ProductDisplayInfo(productId: "monthly", title: "M", localizedPrice: nil, price: nil, localizedPeriod: nil, subscriptionGroupId: nil, isAutoRenewable: true) + #expect(destinations(context(presentation(sub(group: nil), product: noGroup), product: noGroup), curated).isEmpty) + } + + @Test("web store sub: management row always shows; play store: contactSupport only") + func otherStores() { + let url = URL(string: "https://app.superwall.app/manage")! + #expect(destinations(context(presentation(sub(store: .stripe), product: nil), web: url)) == [.webManage(url), .contactSupport]) + // Without a management URL the row stays, explaining where to find the link. Dropping it left + // a paying web customer with no way to manage their subscription at all. + #expect(destinations(context(presentation(sub(store: .stripe), product: nil))) == [.webManageUnavailable, .contactSupport]) + // The Play Store isn't a web store, so it gets neither branch. + #expect(destinations(context(presentation(sub(store: .playStore), product: nil))) == [.contactSupport]) + } + + @Test("family shared hides manage/refund/changePlan; app extension hides url/contact") + func familyAndExtension() { + #expect(destinations(context(presentation(sub(), product: monthly), product: monthly, family: true)) == [.contactSupport]) + var p = paths; p.append(.init(id: "faq", type: .url(URL(string: "https://a.b")!, title: "FAQ", openMethod: .external))) + #expect(destinations(context(nil, canOpen: false), p) == [.restore]) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift new file mode 100644 index 0000000000..163fd582ed --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -0,0 +1,320 @@ +// +// PurchasePresentationBuilderTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("PurchasePresentationBuilder") +struct PurchasePresentationBuilderTests { + let now = Date(timeIntervalSince1970: 1_700_000_000) + var builder: PurchasePresentationBuilder { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return PurchasePresentationBuilder(now: { now }, strings: .english, dateFormatter: formatter) + } + func sub( + _ id: String, + active: Bool = true, + willRenew: Bool = true, + expires: TimeInterval? = 86_400, + revoked: Bool = false, + grace: Bool = false, + retry: Bool = false, + offer: LatestSubscription.OfferType? = nil, + store: ProductStore = .appStore, + group: String? = "g1" + ) -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: "t_\(id)", + productId: id, + purchaseDate: now.addingTimeInterval(-86_400), + willRenew: willRenew, + isRevoked: revoked, + isInGracePeriod: grace, + isInBillingRetryPeriod: retry, + isActive: active, + expirationDate: expires.map { now.addingTimeInterval($0) }, + offerType: offer, + subscriptionGroupId: group, + store: store + ) + } + func info( + subs: [SubscriptionTransaction] = [], + nonSubs: [NonSubscriptionTransaction] = [], + entitlements: [Entitlement] = [] + ) -> CustomerInfo { + CustomerInfo(subscriptions: subs, nonSubscriptions: nonSubs, entitlements: entitlements) + } + let monthly = ProductDisplayInfo( + productId: "monthly", + title: "Monthly", + localizedPrice: "$9.99", + price: 9.99, + localizedPeriod: "month", + subscriptionGroupId: "g1", + isAutoRenewable: true + ) + + @Test("dates format in the injected locale, not the system locale") + func formatsDatesWithInjectedLocale() { + let locale = Locale(identifier: "fr_FR") + let localized = PurchasePresentationBuilder(now: { now }, strings: .english, locale: locale) + let rows = localized.subscriptionPresentations([sub("monthly")], products: [:]) + + let expected = DateFormatter() + expected.dateStyle = .medium + expected.timeStyle = .none + expected.locale = locale + let renewalDate = now.addingTimeInterval(86_400) + #expect(rows.first?.statusLine.contains(expected.string(from: renewalDate)) == true) + } + + @Test("active renewing subscription: Active badge, renews line with price") + func activeRenewing() { + let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: ["monthly": monthly]) + #expect(rows.count == 1) + #expect(rows[0].title == "Monthly") + #expect(rows[0].badge == .active) + #expect(rows[0].priceLine == "$9.99 / month") + #expect(rows[0].statusLine == "Renews on 2023-11-15 for $9.99") + } + + @Test("badge priority: lifetime > revoked > expired > billingIssue > cancelled > freeTrial > active") + func badgePriority() { + let products = ["monthly": monthly] + func badge(_ subscription: SubscriptionTransaction) -> PurchaseBadge { + builder.build(customerInfo: info(subs: [subscription]), products: products)[0].badge + } + #expect(badge(sub("monthly", revoked: true)) == .revoked) + #expect(badge(sub("monthly", active: false, expires: -10)) == .expired) + #expect(badge(sub("monthly", grace: true)) == .billingIssue) + #expect(badge(sub("monthly", retry: true)) == .billingIssue) + #expect(badge(sub("monthly", willRenew: false)) == .cancelled) + #expect(badge(sub("monthly", offer: .trial)) == .freeTrial) + let lifetime = Entitlement(id: "pro", isActive: true, store: .appStore, isLifetime: true) + let lifetimeRows = builder.build(customerInfo: info(entitlements: [lifetime]), products: [:]) + #expect(lifetimeRows[0].badge == .lifetime) + } + + @Test("status lines") + func statusLines() { + let products = ["monthly": monthly] + func status(_ subscription: SubscriptionTransaction) -> String { + builder.build(customerInfo: info(subs: [subscription]), products: products)[0].statusLine + } + #expect(status(sub("monthly", willRenew: false)) == "Expires on 2023-11-15") + #expect(status(sub("monthly", active: false, expires: -86_400)) == "Expired on 2023-11-13") + #expect(status(sub("monthly", offer: .trial)) == "Free trial until 2023-11-15") + #expect(status(sub("monthly", grace: true)) == "Billing issue – update your payment method to keep access") + } + + @Test("missing product shows no title and omits price") + func missingProduct() { + let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: [:]) + #expect(rows[0].title == nil) + #expect(rows[0].priceLine == nil) + #expect(rows[0].statusLine == "Renews on 2023-11-15") + } + + @Test("sorting: active by expiration ascending, inactive last, then non-subs, then entitlement-only") + func sorting() { + let subs = [sub("late", expires: 200), sub("dead", active: false, expires: -5), sub("soon", expires: 100)] + let nonSub = NonSubscriptionTransaction( + transactionId: "n", + productId: "coins", + purchaseDate: now, + isConsumable: true, + isRevoked: false, + store: .appStore + ) + let ent = Entitlement(id: "granted", isActive: true, store: .superwall) + let rows = builder.build(customerInfo: info(subs: subs, nonSubs: [nonSub], entitlements: [ent]), products: [:]) + // Non-subscription rows are keyed by transaction id ("n"), not product id, so repeat + // consumable purchases of the same product each get their own row. + #expect(rows.map(\.id) == ["soon", "late", "dead", "n", "entitlement:granted"]) + } + + @Test("store labels") + func storeLabels() { + func storeLabelKey(_ subscription: SubscriptionTransaction) -> String? { + builder.build(customerInfo: info(subs: [subscription]), products: [:])[0].storeLabelKey + } + #expect(storeLabelKey(sub("w", store: .stripe)) == "customer_center_store_web") + #expect(storeLabelKey(sub("p", store: .playStore)) == "customer_center_store_google_play") + #expect(storeLabelKey(sub("s", store: .superwall)) == "customer_center_store_superwall") + #expect(storeLabelKey(sub("a")) == nil) + } + + @Test("entitlement-only rows are built only for entitlements with no matching transaction") + func entitlementOnly() { + let ent = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .appStore) + let rows = builder.build(customerInfo: info(subs: [sub("monthly")], entitlements: [ent]), products: [:]) + #expect(rows.count == 1) + } + + @Test("inactive subscription with no expiration date falls back to Expired status line") + func expiredWithNoDateFallsBackToExpired() { + let subscription = sub("monthly", active: false, expires: nil) + let rows = builder.build(customerInfo: info(subs: [subscription]), products: [:]) + #expect(rows[0].badge == .expired) + #expect(rows[0].statusLine == "Expired") + } + + @Test("sorting: active subscription with nil expiration date sorts after a dated active subscription") + func nilExpirationSortsAfterDatedActiveSubscription() { + let subs = [sub("no-date", expires: nil), sub("dated", expires: 200)] + let rows = builder.build(customerInfo: info(subs: subs), products: [:]) + #expect(rows.map(\.id) == ["dated", "no-date"]) + } + + // MARK: - Renewal collapsing + + @Test("renewals of one product collapse to a single row, preferring the active transaction") + func renewalsCollapseToActiveRow() { + // Three StoreKit transactions of the same subscription: two lapsed renewal periods plus the + // current one. `CustomerInfo.subscriptions` reports all three; the user has one subscription. + let subs = [ + sub("monthly", active: false, expires: -172_800), + sub("monthly", active: false, expires: -86_400), + sub("monthly", active: true, expires: 86_400) + ] + let rows = builder.build(customerInfo: info(subs: subs), products: ["monthly": monthly]) + #expect(rows.count == 1) + #expect(rows[0].id == "monthly") + #expect(rows[0].badge == .active) + #expect(rows[0].isActive) + #expect(rows[0].subscription?.expirationDate == now.addingTimeInterval(86_400)) + } + + @Test("all-expired renewals collapse to a single row carrying the latest expiration") + func expiredRenewalsCollapseToLatestExpiration() { + let subs = [ + sub("monthly", active: false, expires: -172_800), + sub("monthly", active: false, expires: -3_600), + sub("monthly", active: false, expires: -86_400) + ] + let rows = builder.build(customerInfo: info(subs: subs), products: ["monthly": monthly]) + #expect(rows.count == 1) + #expect(rows[0].badge == .expired) + #expect(rows[0].subscription?.expirationDate == now.addingTimeInterval(-3_600)) + } + + @Test("repeat purchases of the same consumable stay as separate rows with distinct ids") + func repeatConsumablePurchasesKeepDistinctRows() { + func coins(_ transactionId: String, purchasedAgo: TimeInterval) -> NonSubscriptionTransaction { + NonSubscriptionTransaction( + transactionId: transactionId, + productId: "coins", + purchaseDate: now.addingTimeInterval(-purchasedAgo), + isConsumable: true, + isRevoked: false, + store: .appStore + ) + } + let rows = builder.build( + customerInfo: info(nonSubs: [coins("n1", purchasedAgo: 172_800), coins("n2", purchasedAgo: 3_600)]), + products: [:] + ) + #expect(rows.count == 2) + #expect(rows.map(\.id) == ["n1", "n2"]) + #expect(Set(rows.map(\.id)).count == 2) + #expect(rows.allSatisfy { $0.productId == "coins" }) + } + + // MARK: - What heads the card + + /// What a Stripe purchase looks like today: the catalogue resolved the product and priced it, + /// but sent no display name. The purchase is shown regardless — hiding a paying customer's + /// subscription was never on the table — with the entitlement it unlocks as its title. + @Test("a subscription whose product has no name is titled by the entitlement it unlocks") + func unnamedProductFallsBackToEntitlement() { + var unnamed = monthly + unnamed.title = nil + let pro = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .stripe) + let rows = builder.build( + customerInfo: info(subs: [sub("monthly", store: .stripe)], entitlements: [pro]), + products: ["monthly": unnamed] + ) + #expect(rows.count == 1) + #expect(rows[0].title == "pro") + #expect(rows[0].priceLine == "$9.99 / month", "the price still shows") + } + + /// No product name and no entitlement either: the card is still there, just headed by nothing. + /// Never the product identifier — for a Stripe price that reads `test:price_1Tu…:no-trial`. + @Test("with neither a product name nor an entitlement, the card shows no title") + func noNameNoEntitlementShowsNoTitle() { + let coins = NonSubscriptionTransaction( + transactionId: "n", + productId: "coins", + purchaseDate: now, + isConsumable: true, + isRevoked: false, + store: .stripe + ) + let unnamed = ProductDisplayInfo( + productId: "coins", + title: nil, + localizedPrice: "$0.99", + price: 0.99, + localizedPeriod: nil, + subscriptionGroupId: nil, + isAutoRenewable: false + ) + let rows = builder.build(customerInfo: info(nonSubs: [coins]), products: ["coins": unnamed]) + #expect(rows.count == 1, "the purchase is never hidden") + #expect(rows[0].title == nil) + #expect(rows[0].priceLine == "$0.99") + } + + /// A product can grant several entitlements. Pick one stably, so the title doesn't change + /// between renders. + @Test("several entitlements pick the lowest identifier, every time") + func entitlementFallbackIsStable() { + let set: Set = [ + Entitlement(id: "pro", isActive: true, store: .stripe), + Entitlement(id: "beta", isActive: true, store: .stripe) + ] + #expect(PurchasePresentationBuilder.entitlementTitle(set) == "beta") + #expect(PurchasePresentationBuilder.entitlementTitle([]) == nil) + } + + /// The management screen only gives a detail screen — and so a place for the purchase's own + /// actions — to rows that open one. A bare entitlement has to be among them: a web subscriber + /// whose backend sent no transaction is one, and their management link lives on that screen. + @Test("subscriptions and entitlement-only purchases open a detail screen; one-offs don't") + func whichRowsOpenDetail() { + let nonSub = NonSubscriptionTransaction( + transactionId: "n", + productId: "coins", + purchaseDate: now, + isConsumable: true, + isRevoked: false, + store: .appStore + ) + let bareWebEntitlement = Entitlement(id: "pro", isActive: true, store: .stripe) + let rows = builder.build( + customerInfo: info(subs: [sub("monthly")], nonSubs: [nonSub], entitlements: [bareWebEntitlement]), + products: [:] + ) + #expect(rows.map(\.id) == ["monthly", "n", "entitlement:pro"]) + #expect(rows.map(\.opensDetail) == [true, false, true]) + } + + @Test("a product name wins over the entitlement") + func productNameWinsOverEntitlement() { + let pro = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .appStore) + let rows = builder.build( + customerInfo: info(subs: [sub("monthly")], entitlements: [pro]), + products: ["monthly": monthly] + ) + #expect(rows[0].title == "Monthly") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift new file mode 100644 index 0000000000..858a7f2186 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift @@ -0,0 +1,45 @@ +// +// SupportEmailComposerTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("SupportEmailComposer") +struct SupportEmailComposerTests { + let diagnostics = SupportEmailDiagnostics( + userId: "user_1", appVersion: "1.2.3", osVersion: "18.0", deviceModel: "iPhone", + sdkVersion: "4.17.0", activeEntitlementIds: ["pro", "plus"], isSandbox: true + ) + + @Test("nil or blank email yields nil") + func nilEmail() { + #expect(SupportEmailComposer.mailtoURL(email: nil, subject: "s", body: "b", diagnostics: diagnostics) == nil) + #expect(SupportEmailComposer.mailtoURL(email: " ", subject: "s", body: "b", diagnostics: diagnostics) == nil) + } + + @Test("builds a mailto URL with encoded subject and diagnostics body") + func buildsURL() throws { + let url = try #require(SupportEmailComposer.mailtoURL( + email: "help@app.com", subject: "Support Request", body: "Please describe your issue.", diagnostics: diagnostics + )) + #expect(url.scheme == "mailto") + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.path == "help@app.com") + let items = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(items["subject"] == "Support Request") + let body = try #require(items["body"]) + #expect(body.hasPrefix("Please describe your issue.")) + #expect(body.contains("- User ID: user_1")) + #expect(body.contains("- App Version: 1.2.3")) + #expect(body.contains("- OS Version: 18.0")) + #expect(body.contains("- Device: iPhone")) + #expect(body.contains("- SDK Version: 4.17.0")) + #expect(body.contains("- Entitlements: pro, plus")) + #expect(body.contains("- Sandbox: true")) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift new file mode 100644 index 0000000000..a9954fd69a --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -0,0 +1,239 @@ +// +// WebProductPricingTests.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Web product pricing") +struct WebProductPricingTests { + /// Mirrors the `/v1/products` payload for a Stripe product. StoreKit can't resolve one of + /// these, so the Superwall catalogue is the only place its price exists. + private func decodeProduct( + amountInCents: Int, + currency: String = "USD", + name: String? = nil, + identifier: String = "web_pro_monthly", + platform: String = "stripe" + ) throws -> SuperwallProduct { + let nameField = name.map { "\"name\": \"\($0)\"," } ?? "" + let json = """ + { + "object": "product", + "identifier": "\(identifier)", + \(nameField) + "platform": "\(platform)", + "price": { "amount": \(amountInCents), "currency": "\(currency)" }, + "subscription": { + "period": "month", + "period_count": 1, + "trial_period_days": null, + "trial_period_price": null + }, + "entitlements": [{ "identifier": "pro", "type": "SERVICE_LEVEL" }], + "storefront": "USA" + } + """ + return try JSONDecoder().decode(SuperwallProduct.self, from: Data(json.utf8)) + } + + @Test("a catalogue product carries a price the Customer Center can show") + func catalogueProductHasPrice() throws { + let product = try decodeProduct(amountInCents: 999) + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct) + + #expect(display.productId == "web_pro_monthly") + // The payload is in minor units; the card shows a formatted major-unit price. + #expect(display.price == Decimal(9.99)) + #expect(display.localizedPrice?.contains("9.99") == true) + #expect(display.localizedPeriod != nil, "the renewal line reads better with a period") + } + + /// A catalogue product with no display name still gets its card — the price is there, and + /// that's what the catalogue is for. What it doesn't get is a title made from the identifier: + /// `test:price_…:no-trial` is not a name. The name is the backend's to send. + @Test("a web product with no display name keeps its card and shows no title", arguments: [199, 999, 7999]) + func unnamedWebProductShowsNoTitle(amountInCents: Int) throws { + let product = try decodeProduct(amountInCents: amountInCents) + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct) + #expect(display.localizedPrice?.contains(".") == true, "the price itself resolved") + #expect(display.title == nil) + + let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) + let presentations = builder.build( + customerInfo: CustomerInfo(subscriptions: [webSubscription()], nonSubscriptions: [], entitlements: []), + products: ["web_pro_monthly": display] + ) + let card = try #require(presentations.first, "the purchase is never hidden") + #expect(card.title == nil) + #expect(card.priceLine != nil) + } + + /// The same purchase once the catalogue names it: the card appears, titled by the catalogue, + /// with the price on it. + @Test("the card shows a price and the catalogue's name once there is one", arguments: [199, 999, 7999]) + func cardShowsPrice(amountInCents: Int) throws { + let product = try decodeProduct(amountInCents: amountInCents, name: "Pro") + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct, name: product.name) + + let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) + let presentations = builder.build( + customerInfo: CustomerInfo(subscriptions: [webSubscription()], nonSubscriptions: [], entitlements: []), + products: ["web_pro_monthly": display] + ) + let card = try #require(presentations.first) + + #expect(card.priceLine != nil) + #expect(card.statusLine.contains(display.localizedPrice ?? "!"), "the renewal line quotes the price") + #expect(card.title == "Pro") + } + + private func webSubscription() -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: "web_1", + productId: "web_pro_monthly", + purchaseDate: Date().addingTimeInterval(-30 * 86_400), + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: Date().addingTimeInterval(12 * 86_400), + subscriptionGroupId: nil, + store: .stripe + ) + } + + /// The field the backend hasn't shipped yet. Pins that `name` decodes off the payload and that + /// `ProductDisplayInfo` honours it; the call site that passes it through is covered separately, + /// by `catalogueNameReachesTheCard` below. + @Test("uses the catalogue's display name as soon as the payload carries one") + func usesDisplayNameWhenPresent() throws { + let product = try decodeProduct(amountInCents: 999, name: "Pro") + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + + #expect(ProductDisplayInfo(storeProduct, name: product.name).title == "Pro") + #expect(ProductDisplayInfo(storeProduct).title == nil, "no name given, no name used") + } + + @Test("a product with no price still renders, just without one") + func missingPriceDegradesGracefully() throws { + let json = """ + { + "object": "product", + "identifier": "web_pro_monthly", + "platform": "stripe", + "price": null, + "subscription": null, + "entitlements": [], + "storefront": "USA" + } + """ + let product = try JSONDecoder().decode(SuperwallProduct.self, from: Data(json.utf8)) + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct) + #expect(display.price == 0) + } + + // MARK: - The rule that decides what the catalogue is allowed to fill + + private func displayInfo(for identifier: String) throws -> ProductDisplayInfo { + let product = try decodeProduct(amountInCents: 100, identifier: identifier) + return ProductDisplayInfo(StoreProduct(catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []))) + } + + @Test("fills only the products StoreKit couldn't resolve") + func fillsOnlyTheGaps() throws { + let fromStoreKit = try displayInfo(for: "ios_pro_monthly") + let catalogue = [ + try decodeProduct(amountInCents: 999, identifier: "web_pro_monthly"), + try decodeProduct(amountInCents: 500, identifier: "web_unrelated") + ] + + let filled = LiveProductsProvider.fillingGaps( + in: ["ios_pro_monthly": fromStoreKit], + requested: ["ios_pro_monthly", "web_pro_monthly"], + from: catalogue + ) + + #expect(filled.keys.sorted() == ["ios_pro_monthly", "web_pro_monthly"]) + #expect(filled["web_pro_monthly"]?.localizedPrice?.contains("9.99") == true) + #expect(filled["web_unrelated"] == nil, "a catalogue entry nobody asked about is not a gap") + } + + /// The restriction that matters for money. `products(for:)` swallows a failed StoreKit lookup + /// with `try?`, so an App Store product can land in the gap — and the catalogue holds the + /// dashboard's storefront price, not what this customer is actually charged in theirs. + @Test("never fills an App Store product from the catalogue") + func neverFillsAppStoreProducts() throws { + let catalogue = [try decodeProduct(amountInCents: 999, identifier: "ios_pro_monthly", platform: "ios")] + + let filled = LiveProductsProvider.fillingGaps( + in: [:], + requested: ["ios_pro_monthly"], + from: catalogue + ) + + #expect(filled.isEmpty, "better no price than a price from the wrong storefront") + } + + @Test("what StoreKit resolved is never overwritten") + func storeKitWins() throws { + let fromStoreKit = try displayInfo(for: "web_pro_monthly") + let catalogue = [try decodeProduct(amountInCents: 9_999, identifier: "web_pro_monthly")] + + let filled = LiveProductsProvider.fillingGaps( + in: ["web_pro_monthly": fromStoreKit], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled["web_pro_monthly"]?.localizedPrice == fromStoreKit.localizedPrice) + } + + /// The other half of `usesDisplayNameWhenPresent`: that the call site actually passes the name + /// through. Deleting `name:` from `fillingGaps` leaves that test green and fails this one. + @Test("the catalogue's name reaches the card") + func catalogueNameReachesTheCard() throws { + let catalogue = [try decodeProduct(amountInCents: 999, name: "Pro")] + + let filled = LiveProductsProvider.fillingGaps( + in: [:], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled["web_pro_monthly"]?.title == "Pro") + } + + @Test("nothing missing means nothing to do") + func noGapsNoWork() throws { + let fromStoreKit = try displayInfo(for: "web_pro_monthly") + let catalogue = [try decodeProduct(amountInCents: 9_999, identifier: "other")] + + let filled = LiveProductsProvider.fillingGaps( + in: ["web_pro_monthly": fromStoreKit], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled.count == 1) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift new file mode 100644 index 0000000000..206e61bf73 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift @@ -0,0 +1,262 @@ +// +// WebSubscriptionPathTests.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Web subscription paths") +@MainActor +struct WebSubscriptionPathTests { + private let managementURL = URL(string: "https://superwall.app/manage")! + + private func webSubscription( + store: ProductStore = .stripe, + isActive: Bool = true, + isRevoked: Bool = false + ) -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: "web_1", + productId: "web_pro_monthly", + purchaseDate: Date().addingTimeInterval(-30 * 86_400), + willRenew: true, + isRevoked: isRevoked, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: isActive, + expirationDate: Date().addingTimeInterval(12 * 86_400), + subscriptionGroupId: nil, + store: store + ) + } + + private func makeViewModel( + store: ProductStore = .stripe, + webManagementURL: URL?, + survey: CustomerCenterConfiguration.FeedbackSurvey? = nil, + subscriptions: [SubscriptionTransaction]? = nil + ) async -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + subscriptions: subscriptions ?? [webSubscription(store: store)], + nonSubscriptions: [], + entitlements: [] + ), + environment: EnvironmentMock(webManagementURL: webManagementURL) + ) + let configuration = CustomerCenterConfiguration.default + configuration.support.webManagementURL = webManagementURL + if let survey { + for path in configuration.managementScreen.paths where path.type == .manageSubscription { + path.survey = survey + } + } + let viewModel = CustomerCenterViewModel( + configuration: configuration, + dependencies: deps, + strings: .english + ) + await viewModel.load() + return viewModel + } + + private func managePath(_ viewModel: CustomerCenterViewModel) -> ResolvedPath? { + let purchase = viewModel.purchases.first + return viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } + } + + // MARK: - Only one row, and it goes to the management page + + @available(iOS 15.0, *) + @Test("a web subscriber gets the management row and nothing App Store-only") + func webSubscriberSeesOneManagementRow() async { + let viewModel = await makeViewModel(webManagementURL: managementURL) + let purchase = viewModel.purchases.first + let types = viewModel.paths(for: purchase).map(\.path.type) + + #expect(types.contains(.manageSubscription)) + #expect(!types.contains { if case .changePlan = $0 { return true } else { return false } }) + #expect(!types.contains { if case .refund = $0 { return true } else { return false } }) + #expect(managePath(viewModel)?.destination == .webManage(managementURL)) + } + + @available(iOS 15.0, *) + @Test("the management row survives a missing management URL", arguments: [ + ProductStore.stripe, .paddle, .superwall + ]) + func rowRemainsWithoutAManagementURL(store: ProductStore) async { + let viewModel = await makeViewModel(store: store, webManagementURL: nil) + // Without this the row vanishes and a paying customer has no way to manage their subscription. + #expect(managePath(viewModel)?.destination == .webManageUnavailable) + } + + @available(iOS 15.0, *) + @Test("tapping the row without a URL explains where to find the link") + func unavailableRowShowsTheBlurb() async { + let viewModel = await makeViewModel(webManagementURL: nil) + let resolved = try? #require(managePath(viewModel)) + guard let resolved else { return } + + await viewModel.select(resolved, purchase: viewModel.purchases.first) + #expect(viewModel.sheet == .webManageUnavailable) + } + + @available(iOS 15.0, *) + @Test("tapping the row with a URL opens the management page") + func availableRowOpensTheManagementPage() async { + let viewModel = await makeViewModel(webManagementURL: managementURL) + let resolved = try? #require(managePath(viewModel)) + guard let resolved else { return } + + await viewModel.select(resolved, purchase: viewModel.purchases.first) + #expect(viewModel.sheet == .safari(managementURL)) + } + + private func makeEntitlementOnlyViewModel(store: EntitlementStore?) async -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "pro", store: store)] + ), + environment: EnvironmentMock(webManagementURL: nil) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + return viewModel + } + + /// An entitlement with no transaction *and* no store behind it — comped, or granted by hand — + /// is reported by the builder as `.superwall`, which reads as a web store. Sending that customer + /// to a management page, or telling them to find a link in a receipt they never got, is wrong. + /// + /// `Entitlement(id:)` is no help here: the public convenience initializer hardcodes + /// `store: .appStore`, so the purchase never reaches the web branch and the case passes on the + /// App Store branch's `guard let sub` instead — green with this rule deleted. + @available(iOS 15.0, *) + @Test("a comped entitlement isn't told to check a receipt it never had") + func compedEntitlementGetsNoReceiptBlurb() async { + let viewModel = await makeEntitlementOnlyViewModel(store: nil) + + let purchase = viewModel.purchases.first + #expect(purchase?.store == .superwall, "otherwise this never reaches the branch under test") + let manage = viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } + #expect(manage == nil, "nothing to manage, so no row at all") + } + + /// The other half of that rule, and the reason it keys on the store rather than the kind: a web + /// purchase arrives as a bare entitlement whenever the backend sends no matching transaction. + /// Those customers are paying, and the row telling them where the link is has to survive. + @available(iOS 15.0, *) + @Test("a web purchase with no transaction behind it keeps its management row", arguments: [ + EntitlementStore.stripe, .paddle + ]) + func webEntitlementWithoutATransactionKeepsTheRow(store: EntitlementStore) async { + let viewModel = await makeEntitlementOnlyViewModel(store: store) + + let purchase = viewModel.purchases.first + let manage = viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } + #expect(manage != nil, "a paying customer must still be told where to manage this") + #expect(manage?.destination == .webManageUnavailable) + } + + /// The App Store branch gates on the subscription being live; the web branch gated on the store + /// alone, so anything that merely *came from* a web store was offered a management row — a + /// single Stripe charge with no subscription behind it, and a subscription that had already + /// lapsed or been revoked. + @available(iOS 15.0, *) + @Test("nothing left to manage means no management row") + func nothingToManageMeansNoRow() async { + let lapsed = webSubscription(isActive: false, isRevoked: false) + let revoked = webSubscription(isActive: true, isRevoked: true) + + for subscription in [lapsed, revoked] { + let viewModel = await makeViewModel( + webManagementURL: managementURL, + subscriptions: [subscription] + ) + let manage = viewModel.paths(for: viewModel.purchases.first) + .first { $0.path.type == .manageSubscription } + #expect(manage == nil, "a subscription that has ended has nothing to manage") + } + } + + @available(iOS 15.0, *) + @Test("a one-off web purchase has no subscription to manage") + func oneOffWebPurchaseHasNoRow() async { + let purchase = NonSubscriptionTransaction( + transactionId: "web_1", + productId: "web_lifetime", + purchaseDate: Date().addingTimeInterval(-86_400), + isConsumable: false, + isRevoked: false, + store: .stripe + ) + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [purchase], entitlements: []), + environment: EnvironmentMock(webManagementURL: managementURL) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + + let manage = viewModel.paths(for: viewModel.purchases.first) + .first { $0.path.type == .manageSubscription } + #expect(manage == nil, "there is no subscription behind a one-time charge") + } + + // MARK: - Surveys don't belong on a web flow + + /// The survey gates an action. On a web flow that action leaves the app — or, with no URL, can't + /// happen at all — so asking the question here collects an answer for something we never see + /// the outcome of. + @available(iOS 15.0, *) + @Test("no survey is shown before handing off to the web", arguments: [true, false]) + func webFlowsSkipTheSurvey(hasManagementURL: Bool) async { + let survey = CustomerCenterConfiguration.FeedbackSurvey( + id: "cancel_survey", + title: "Why are you cancelling?", + options: [.init(id: "too_expensive", title: "Too expensive")] + ) + let viewModel = await makeViewModel( + webManagementURL: hasManagementURL ? managementURL : nil, + survey: survey + ) + let resolved = try? #require(managePath(viewModel)) + guard let resolved else { return } + + await viewModel.select(resolved, purchase: viewModel.purchases.first) + + #expect(viewModel.pendingSurvey == nil) + if case .survey = viewModel.sheet { + Issue.record("a web flow should not present the survey") + } + } + + // MARK: - Labelling + + @available(iOS 15.0, *) + @Test("web management destinations are labelled as managing, not cancelling") + func webDestinationsAreLabelledAsManagement() { + #expect(ResolvedPathDestination.webManage(managementURL).isWebManagement) + #expect(ResolvedPathDestination.webManageUnavailable.isWebManagement) + #expect(!ResolvedPathDestination.appleManageSheet(subscriptionGroupId: "g").isWebManagement) + #expect(!ResolvedPathDestination.restore.isWebManagement) + + // The label the row actually renders differs between the two, which is the point. + let strings = CustomerCenterStrings.english + #expect(strings.string("customer_center_path_manage_subscription") == "Cancel subscription") + #expect(strings.string("customer_center_path_manage_subscription_web") == "Manage subscription") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift new file mode 100644 index 0000000000..eaf448030e --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift @@ -0,0 +1,41 @@ +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterAction") +struct CustomerCenterActionTests { + @Test("maps every PathType to the corresponding action") + func fromPathType() { + let url = URL(string: "https://a.b")! + #expect(CustomerCenterAction(pathType: .restore) == .restore) + #expect(CustomerCenterAction(pathType: .manageSubscription) == .manageSubscription) + #expect(CustomerCenterAction(pathType: .refund(window: 1)) == .refund) + #expect(CustomerCenterAction(pathType: .changePlan(productIds: nil)) == .changePlan) + #expect(CustomerCenterAction(pathType: .contactSupport) == .contactSupport) + #expect(CustomerCenterAction(pathType: .url(url, title: "Help", openMethod: .external)) == .url(url)) + #expect(CustomerCenterAction(pathType: .custom(identifier: "x")) == .custom(identifier: "x")) + } + + @Test("analytics name is stable") + func analyticsName() { + #expect(CustomerCenterAction.restore.analyticsName == "restore") + #expect(CustomerCenterAction.manageSubscription.analyticsName == "manage_subscription") + #expect(CustomerCenterAction.refund.analyticsName == "refund") + #expect(CustomerCenterAction.changePlan.analyticsName == "change_plan") + #expect(CustomerCenterAction.contactSupport.analyticsName == "contact_support") + #expect(CustomerCenterAction.url(URL(string: "https://a.b")!).analyticsName == "url") + #expect(CustomerCenterAction.custom(identifier: "x").analyticsName == "custom") + } + + @Test("ObjC path factories round-trip") + func objcFactories() { + let path = CustomerCenterConfiguration.Path.url(id: "faq", url: URL(string: "https://a.b")!, openMethod: .inApp, title: "FAQ") + #expect(path.pathType == .url) + #expect(path.url?.absoluteString == "https://a.b") + #expect(path.openMethodObjc == .inApp) + let custom = CustomerCenterConfiguration.Path.custom(id: "c", identifier: "delete", title: nil) + #expect(custom.customIdentifier == "delete") + let refund = CustomerCenterConfiguration.Path.refund(id: "r", window: 60, title: nil) + #expect(refund.refundWindow?.doubleValue == 60) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift new file mode 100644 index 0000000000..b60a5f8c63 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterConfiguration") +struct CustomerCenterConfigurationTests { + @Test("default has management paths restore/changePlan/refund/manage(with survey)/contactSupport and no-purchases restore") + func defaultShape() { + let config = CustomerCenterConfiguration.default + #expect(config.managementScreen.paths.map(\.id) == ["restore", "change_plan", "refund", "manage_subscription", "contact_support"]) + #expect(config.noPurchasesScreen.paths.map(\.id) == ["restore"]) + let manage = config.managementScreen.paths[3] + #expect(manage.type == .manageSubscription) + #expect(manage.survey?.id == "cancel_survey") + #expect(manage.survey?.options.map(\.id) == ["too_expensive", "dont_use", "bought_by_mistake"]) + #expect(config.support.email == nil) + #expect(config.support.shouldWarnToUpdate == true) + #expect(config.showsAccountDetails && config.warnsAboutDuplicateSubscriptions) + } + + @Test("default returns a fresh instance each time") + func defaultIsFresh() { + let a = CustomerCenterConfiguration.default + a.support.email = "x@y.z" + #expect(CustomerCenterConfiguration.default.support.email == nil) + } + + @Test("round-trips through JSON including PathType payloads") + func codableRoundTrip() throws { + let config = CustomerCenterConfiguration.default + config.support.email = "help@app.com" + config.support.latestAppVersion = "2.1.0" + config.support.webManagementURL = URL(string: "https://app.superwall.app/manage") + config.appearance.accent = .init(light: "#112233", dark: "#AABBCC") + config.managementScreen.paths.append(.init(id: "faq", type: .url(URL(string: "https://app.com/faq")!, title: "FAQ", openMethod: .inApp))) + config.managementScreen.paths.append(.init(id: "del", type: .custom(identifier: "delete_account"))) + config.managementScreen.paths.append(.init(id: "ref", type: .refund(window: 3600))) + config.managementScreen.paths.append(.init(id: "chg", type: .changePlan(productIds: ["a", "b"]))) + + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CustomerCenterConfiguration.self, from: data) + #expect(decoded == config) + #expect(decoded.managementScreen.paths.last?.type == .changePlan(productIds: ["a", "b"])) + } + + @Test("equal configurations hash equally, including after a Codable round-trip") + func hashMatchesEquality() throws { + let config = CustomerCenterConfiguration.default + config.support.email = "help@app.com" + config.support.latestAppVersion = "2.1.0" + config.appearance.accent = .init(light: "#112233", dark: "#AABBCC") + + // Decoding creates distinct instances, so identity-based hashing would diverge here even + // though the values compare equal. + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(CustomerCenterConfiguration.self, from: data) + #expect(decoded == config) + #expect(decoded.hash == config.hash) + #expect(decoded.support.hash == config.support.hash) + #expect(decoded.appearance.hash == config.appearance.hash) + #expect(decoded.appearance.accent?.hash == config.appearance.accent?.hash) + } + + @Test("SuperwallOptions exposes a default customerCenter configuration") + func optionsDefault() { + let options = SuperwallOptions() + #expect(options.customerCenter == CustomerCenterConfiguration.default) + options.customerCenter.support.email = "a@b.c" + #expect(options.customerCenter.support.email == "a@b.c") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift new file mode 100644 index 0000000000..fb0492447d --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift @@ -0,0 +1,54 @@ +// +// CustomerCenterDelegateAdapterTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterDelegateAdapter") +@MainActor +struct CustomerCenterDelegateAdapterTests { + final class SwiftDelegate: CustomerCenterDelegate { + var restoreGateProceeds = true + var selected: [CustomerCenterAction] = [] + var surveys: [(String, String, CustomerCenterAction)] = [] + var refunds: [(String, CustomerCenterRefundStatus)] = [] + var dismissed = 0 + func customerCenter(shouldRestorePurchases resume: @escaping (Bool) -> Void) { resume(restoreGateProceeds) } + func customerCenter(didSelect action: CustomerCenterAction, for purchase: SubscriptionTransaction?) { selected.append(action) } + func customerCenter(didCompleteSurvey surveyId: String, optionId: String, for action: CustomerCenterAction) { + surveys.append((surveyId, optionId, action)) + } + func customerCenter(didCompleteRefundRequestFor productId: String, status: CustomerCenterRefundStatus) { + refunds.append((productId, status)) + } + func customerCenterDidDismiss() { dismissed += 1 } + } + + @Test("forwards every callback to a Swift delegate") + func forwardsSwift() async { + let delegate = SwiftDelegate() + let callbacks = CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil).makeCallbacks() + var proceeded: Bool? + callbacks.shouldRestore?({ proceeded = $0 }) + #expect(proceeded == true) + callbacks.didSelectAction?(.refund, nil) + callbacks.didCompleteSurvey?("s", "o", .manageSubscription) + callbacks.didCompleteRefund?("p", .success) + callbacks.didDismiss?() + #expect(delegate.selected == [.refund]) + #expect(delegate.surveys.first?.1 == "o") + #expect(delegate.refunds.first?.1 == .success) + #expect(delegate.dismissed == 1) + } + + @Test("no delegate: shouldRestore is nil so the view model proceeds") + func noDelegate() { + let callbacks = CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: nil).makeCallbacks() + #expect(callbacks.shouldRestore == nil) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift new file mode 100644 index 0000000000..3359dcabcc --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -0,0 +1,270 @@ +// +// CustomerCenterViewControllerTests.swift +// +// +// Created by Jordan Morgan on 25/08/2026. +// + +import Testing +import Foundation +import UIKit +@testable import SuperwallKit + +@Suite("CustomerCenterViewController presentation styles", .serialized) +@MainActor +struct CustomerCenterViewControllerTests { + // MARK: - Fixtures + + private final class ProbeDelegate: CustomerCenterDelegate { + var didDismissCount = 0 + func customerCenterDidDismiss() { didDismissCount += 1 } + } + + @available(iOS 15.0, *) + private func makeController( + style: CustomerCenterPresentationStyle, + delegate: CustomerCenterDelegate?, + dismissDebounceInterval: TimeInterval = 0.6 + ) -> CustomerCenterViewController { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english, + dismissDebounceInterval: dismissDebounceInterval + ) + return CustomerCenterViewController( + viewModel: viewModel, + adapter: CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil), + presentationStyle: style + ) + } + + private func makeWindow(rootViewController: UIViewController) -> UIWindow { + let window: UIWindow + if let scene = UIApplication.sharedApplication?.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: scene) + window.frame = scene.screen.bounds + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + window.rootViewController = rootViewController + return window + } + + private func spinRunLoop(timeout: TimeInterval, until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } + + // MARK: - Being covered is not being dismissed + + /// The regression this whole style split exists for. A pushed controller has + /// `presentingViewController == nil` for its entire lifetime, so the previous teardown check + /// treated every cover event — a push on top, a tab switch — as the Customer Center closing. + /// `dismiss()` latches, so that also permanently silenced the real teardown. + @available(iOS 15.0, *) + @Test("pushed: being covered on the host's stack does not fire the dismissal") + func pushedCoveredDoesNotDismiss() { + let delegate = ProbeDelegate() + let controller = makeController(style: .pushed, delegate: delegate) + var onDismissCount = 0 + controller.onDismiss = { onDismissCount += 1 } + + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + // The host pushes its own screen on top. UIKit leaves `isBeingDismissed` and + // `isMovingFromParent` false here — we are covered, not removed. + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + controller.viewDidDisappear(false) + + #expect(delegate.didDismissCount == 0) + #expect(onDismissCount == 0) + + window.isHidden = true + } + + /// The synchronous cover test above only proves nothing fires *immediately*. The view model also + /// arms a debounced dismissal from SwiftUI's `onDisappear`, which a `UIHostingController` + /// forwards on a cover just as it does on a teardown — so without a veto the dismissal simply + /// arrives late, and the latch then silences the genuine pop. This waits past the debounce. + @available(iOS 15.0, *) + @Test("pushed: a cover does not fire a late dismissal, and the real pop still does") + func pushedCoverDoesNotFireLateDismissal() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let controller = makeController(style: .pushed, delegate: delegate, dismissDebounceInterval: debounce) + + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + // Covered by the host's own screen. + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + + // Arm the debounce explicitly rather than trusting SwiftUI's `onDisappear` to have fired in + // this harness: with nothing armed the test would pass whether or not the veto works at all. + // Armed *before* the veto is the ordering the veto assumes, and the easier of the two. + controller.viewModel.surfaceDidDisappear() + controller.viewDidDisappear(false) + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 0, "a cover must not deliver a dismissal, even late") + + // And the genuine teardown afterwards must still be delivered — the premature fire would have + // latched `didDismiss` and made this silent. + navigation.popToRootViewController(animated: false) + spinRunLoop(timeout: 1) { delegate.didDismissCount > 0 } + #expect(delegate.didDismissCount == 1) + + window.isHidden = true + } + + /// The ordering the veto cannot assume. Apple documents `onDisappear`'s exact moment as + /// depending on the view type and ties it to no UIKit callback, so SwiftUI may deliver it a + /// runloop turn after `viewDidDisappear` returns — arming the debounce with nothing left to + /// cancel it. A veto that only drops an already-armed dismissal passes the test above and fires + /// the premature `didDismiss` here, latching and silencing the genuine teardown afterwards. + @available(iOS 15.0, *) + @Test("pushed: a cover survives SwiftUI reporting the disappearance late") + func pushedCoverSurvivesALateDisappearance() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let controller = makeController(style: .pushed, delegate: delegate, dismissDebounceInterval: debounce) + + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + + // The veto runs first, and only then does SwiftUI get round to reporting the disappearance. + controller.viewDidDisappear(false) + controller.viewModel.surfaceDidDisappear() + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 0, "a late disappearance must not resurrect the dismissal") + + // And the real teardown still lands, which the premature fire would have latched away. + navigation.popToRootViewController(animated: false) + spinRunLoop(timeout: 1) { delegate.didDismissCount > 0 } + #expect(delegate.didDismissCount == 1) + + window.isHidden = true + } + + @available(iOS 15.0, *) + @Test("pushed: being popped off the host's stack fires the dismissal exactly once") + func pushedPopFiresDismissal() { + let delegate = ProbeDelegate() + let controller = makeController(style: .pushed, delegate: delegate) + var onDismissCount = 0 + controller.onDismiss = { onDismissCount += 1 } + + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + navigation.popViewController(animated: false) + spinRunLoop(timeout: 1) { delegate.didDismissCount > 0 } + + #expect(delegate.didDismissCount == 1) + #expect(onDismissCount == 1) + + window.isHidden = true + } + + @available(iOS 15.0, *) + @Test("modal: dismissing fires the dismissal exactly once") + func modalDismissFiresDismissal() { + let delegate = ProbeDelegate() + let controller = makeController(style: .modal, delegate: delegate) + var onDismissCount = 0 + controller.onDismiss = { onDismissCount += 1 } + + // A hostless test target never drives a modal transition to completion, so UIKit never + // populates `presentingViewController` and a real `present(_:animated:)` here would leave the + // controller unable to tell it had ever been presented. Set the one fact UIKit would have + // recorded during `viewDidAppear`, then let the real teardown check run against it. + controller.wasPresentedModally = true + #expect(controller.presentingViewController == nil, "a dismissed modal has no presenter left") + + controller.viewDidDisappear(false) + + #expect(delegate.didDismissCount == 1) + #expect(onDismissCount == 1) + } + + /// A controller that was never presented and is not being removed is not a teardown. This is the + /// case the old `presentingViewController == nil` check got wrong, since it is indistinguishable + /// from a pushed controller sitting on a back stack. + @available(iOS 15.0, *) + @Test("a controller that was never presented does not report a dismissal") + func neverPresentedDoesNotDismiss() { + let delegate = ProbeDelegate() + let controller = makeController(style: .modal, delegate: delegate) + var onDismissCount = 0 + controller.onDismiss = { onDismissCount += 1 } + + controller.viewDidDisappear(false) + + #expect(delegate.didDismissCount == 0) + #expect(onDismissCount == 0) + } + + // MARK: - Chrome + + /// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on + /// the way out. It no longer touches the bar in any style — the host's chrome is theirs. + @available(iOS 15.0, *) + @Test("neither style modifies the host's navigation bar", arguments: [ + CustomerCenterPresentationStyle.pushed, .modal + ]) + func neitherStyleTouchesTheHostBar(style: CustomerCenterPresentationStyle) { + let controller = makeController(style: style, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + navigation.setNavigationBarHidden(false, animated: false) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + let recognizer = navigation.interactivePopGestureRecognizer + let hostDelegate = recognizer?.delegate + let hostEnabled = recognizer?.isEnabled + + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + #expect(!navigation.isNavigationBarHidden, "the host's bar stays visible") + #expect(recognizer?.delegate === hostDelegate, "the pop gesture is left alone") + #expect(recognizer?.isEnabled == hostEnabled) + + window.isHidden = true + } + + + + // MARK: - Analytics + + @available(iOS 15.0, *) + @Test("presentation style is reported on Customer Center events") + func reportsPresentationMode() { + #expect(makeController(style: .modal, delegate: nil).viewModel.presentationMode == "sheet") + #expect(makeController(style: .pushed, delegate: nil).viewModel.presentationMode == "pushed") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift new file mode 100644 index 0000000000..c0d2e62242 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift @@ -0,0 +1,121 @@ +// +// CustomerCenterDependenciesMocks.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Combine +import Foundation +@testable import SuperwallKit + +final class CustomerInfoProviderMock: CustomerCenterCustomerInfoProviding { + let subject: CurrentValueSubject + var fetchCount = 0 + var refreshReceiptsCount = 0 + /// `true` once the view model asked for a receipt-backed refresh rather than a cached read. + var didRefreshReceipts: Bool { refreshReceiptsCount > 0 } + init(_ info: CustomerInfo) { subject = .init(info) } + func fetchCustomerInfo() async -> CustomerInfo { fetchCount += 1; return subject.value } + func refreshReceipts() async -> CustomerInfo { refreshReceiptsCount += 1; return subject.value } + var customerInfoPublisher: AnyPublisher { subject.eraseToAnyPublisher() } +} +final class ProductsProviderMock: CustomerCenterProductsProviding { + var products: [String: ProductDisplayInfo] = [:] + var requested: Set = [] + func products(for ids: Set) async -> [String: ProductDisplayInfo] { requested = ids; return products.filter { ids.contains($0.key) } } +} +final class RestorerMock: CustomerCenterRestoring { + var result: RestorationResult = .restored + var calls = 0 + func restorePurchases() async -> RestorationResult { calls += 1; return result } +} +final class URLOpenerMock: CustomerCenterURLOpening { + var canOpenURLs = true + var openable = true + var opened: [URL] = [] + func canOpen(_ url: URL) -> Bool { openable } + func open(_ url: URL) { opened.append(url) } +} +final class EventTrackerMock: CustomerCenterEventTracking { + var events: [SuperwallEvent] = [] + func track(_ event: Trackable) async { + if let event = event as? TrackableSuperwallEvent { events.append(event.superwallEvent) } + } +} +struct EnvironmentMock: CustomerCenterEnvironmentProviding { + var appVersion = "1.0.0" + var osVersion = "18.0" + var deviceModel = "iPhone" + var sdkVersion = "4.17.0" + var userId = "user_1" + var isSandbox = false + var appStoreURL: URL? = URL(string: "https://apps.apple.com/app/id1") + var webManagementURL: URL? + var isSimulator = false + var isAppExtension = false + var originalDownloadDate: Date? = Date(timeIntervalSince1970: 0) + var locale = Locale(identifier: "en_US") + + init( + appVersion: String = "1.0.0", + osVersion: String = "18.0", + deviceModel: String = "iPhone", + sdkVersion: String = "4.17.0", + userId: String = "user_1", + isSandbox: Bool = false, + appStoreURL: URL? = URL(string: "https://apps.apple.com/app/id1"), + webManagementURL: URL? = nil, + isSimulator: Bool = false, + isAppExtension: Bool = false, + originalDownloadDate: Date? = Date(timeIntervalSince1970: 0), + locale: Locale = Locale(identifier: "en_US") + ) { + self.appVersion = appVersion + self.osVersion = osVersion + self.deviceModel = deviceModel + self.sdkVersion = sdkVersion + self.userId = userId + self.isSandbox = isSandbox + self.appStoreURL = appStoreURL + self.webManagementURL = webManagementURL + self.isSimulator = isSimulator + self.isAppExtension = isAppExtension + self.originalDownloadDate = originalDownloadDate + self.locale = locale + } +} +final class AppStoreVersionProviderMock: CustomerCenterAppStoreVersionProviding { + var version: String? + var callCount = 0 + init(version: String? = nil) { self.version = version } + func latestAppStoreVersion() async -> String? { callCount += 1; return version } +} + +extension CustomerCenterDependencies { + static func mock( + info: CustomerInfo, + products: [String: ProductDisplayInfo] = [:], + environment: EnvironmentMock = EnvironmentMock(), + restorer: RestorerMock = RestorerMock(), + urlOpener: URLOpenerMock = URLOpenerMock(), + tracker: EventTrackerMock = EventTrackerMock(), + lookup: StoreKitTransactionLookupMock = StoreKitTransactionLookupMock(), + appStoreVersion: AppStoreVersionProviderMock = AppStoreVersionProviderMock() + ) -> (CustomerCenterDependencies, CustomerInfoProviderMock, ProductsProviderMock) { + let infoProvider = CustomerInfoProviderMock(info) + let productsProvider = ProductsProviderMock() + productsProvider.products = products + let deps = CustomerCenterDependencies( + customerInfo: infoProvider, + products: productsProvider, + restore: restorer, + urlOpener: urlOpener, + tracker: tracker, + environment: environment, + transactionLookup: lookup, + appStoreVersion: appStoreVersion + ) + return (deps, infoProvider, productsProvider) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift new file mode 100644 index 0000000000..c8c9b0b9df --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift @@ -0,0 +1,56 @@ +// +// CustomerCenterDependenciesTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit +import StoreKit + +@Suite("CustomerCenterDependencies") +struct CustomerCenterDependenciesTests { + @Test("web management URL: override wins; superwall.app host → /manage; other host → restore URL; none → nil") + func webURL() { + let override = URL(string: "https://me.com/manage")! + let restore = URL(string: "https://caffeinepal.superwall.app/restore?x=1")! + #expect(WebManagementURLResolver.resolve(override: override, restoreAccessURL: restore) == override) + #expect(WebManagementURLResolver.resolve(override: nil, restoreAccessURL: restore) == URL(string: "https://caffeinepal.superwall.app/manage")) + let other = URL(string: "https://example.com/restore")! + #expect(WebManagementURLResolver.resolve(override: nil, restoreAccessURL: other) == other) + #expect(WebManagementURLResolver.resolve(override: nil, restoreAccessURL: nil) == nil) + // Lookalike host: a suffix match without a dot boundary would wrongly treat this as + // a superwall.app subdomain and rewrite it. It must pass through unchanged. + let lookalike = URL(string: "https://notsuperwall.app/restore?x=1")! + #expect(WebManagementURLResolver.resolve(override: nil, restoreAccessURL: lookalike) == lookalike) + } + + @Test("ProductDisplayInfo init: title present, group id passes through, no period, not auto-renewable for SK1-only product") + func productDisplayInfoFromSK1WithTitle() { + let sk1 = MockSkProduct( + productIdentifier: "monthly", + subscriptionGroupIdentifier: "group_1", + localizedTitle: "Monthly Plan" + ) + let storeProduct = StoreProduct(sk1Product: sk1, entitlements: []) + let info = ProductDisplayInfo(storeProduct) + + #expect(info.title == "Monthly Plan") + #expect(info.subscriptionGroupId == "group_1") + #expect(info.localizedPeriod == nil) + #expect(info.isAutoRenewable == nil) + } + + /// The identifier is never promoted to a title. A product with no name has no name. + @Test("ProductDisplayInfo init: title is nil when the sk1 title is empty") + func productDisplayInfoFromSK1WithoutTitle() { + let sk1 = MockSkProduct(productIdentifier: "monthly") + let storeProduct = StoreProduct(sk1Product: sk1, entitlements: []) + let info = ProductDisplayInfo(storeProduct) + + #expect(info.title == nil) + #expect(info.productId == "monthly") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift new file mode 100644 index 0000000000..7497f575aa --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -0,0 +1,469 @@ +// +// CustomerCenterViewModelTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("CustomerCenterViewModel") +@MainActor +struct CustomerCenterViewModelTests { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let monthly = ProductDisplayInfo(productId: "monthly", title: "Monthly", localizedPrice: "$9.99", price: 9.99, + localizedPeriod: "month", subscriptionGroupId: "g1", isAutoRenewable: true) + + func sub(store: ProductStore = .appStore, willRenew: Bool = true) -> SubscriptionTransaction { + SubscriptionTransaction(transactionId: "t", productId: "monthly", purchaseDate: now.addingTimeInterval(-86_400), willRenew: willRenew, + isRevoked: false, isInGracePeriod: false, isInBillingRetryPeriod: false, isActive: true, + expirationDate: now.addingTimeInterval(86_400), offerType: nil, subscriptionGroupId: "g1", store: store) + } + + func info(_ subs: [SubscriptionTransaction]) -> CustomerInfo { CustomerInfo(subscriptions: subs, nonSubscriptions: [], entitlements: []) } + + func make(info: CustomerInfo, config: CustomerCenterConfiguration = .default, env: EnvironmentMock = EnvironmentMock(), + restorer: RestorerMock = RestorerMock(), opener: URLOpenerMock = URLOpenerMock(), tracker: EventTrackerMock = EventTrackerMock(), + lookup: StoreKitTransactionLookupMock = StoreKitTransactionLookupMock()) + -> (CustomerCenterViewModel, CustomerInfoProviderMock, ProductsProviderMock) { + let (deps, infoMock, productsMock) = CustomerCenterDependencies.mock(info: info, products: ["monthly": monthly], environment: env, + restorer: restorer, urlOpener: opener, tracker: tracker, lookup: lookup) + let vm = CustomerCenterViewModel(configuration: config, dependencies: deps, strings: .english, isChangePlanSheetAvailable: true) + return (vm, infoMock, productsMock) + } + + @Test("load: fetches fresh info + products, picks management screen, tracks open") + func loadManagement() async { + let tracker = EventTrackerMock() + let (vm, infoMock, productsMock) = make(info: info([sub()]), tracker: tracker) + await vm.load() + #expect(infoMock.fetchCount == 1) + #expect(productsMock.requested == ["monthly"]) + #expect(vm.state == .management) + #expect(vm.purchases.map(\.id) == ["monthly"]) + if case .customerCenterOpen(let screen) = tracker.events.first { + #expect(screen == "management") + } else { + Issue.record("expected a customerCenterOpen event") + } + } + + @Test("load: no purchases → noPurchases") + func loadNoPurchases() async { + let (vm, _, _) = make(info: info([])) + await vm.load() + #expect(vm.state == .noPurchases) + } + + @Test("support diagnostics list active entitlement ids, not product ids") + func supportDiagnosticsUseEntitlementIds() async throws { + let config = CustomerCenterConfiguration.default + config.support.email = "help@app.com" + let customerInfo = CustomerInfo( + subscriptions: [sub()], + nonSubscriptions: [], + entitlements: [Entitlement(id: "pro"), Entitlement(id: "lapsed", isActive: false)] + ) + let (vm, _, _) = make(info: customerInfo, config: config) + await vm.load() + + let url = try #require(vm.supportMailtoURL) + let body = try #require( + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "body" }?.value + ) + #expect(body.contains("- Entitlements: pro")) + #expect(!body.contains("monthly")) + } + + @Test("update banner only when latestAppVersion is newer and warn enabled") + func updateBanner() async { + let config = CustomerCenterConfiguration.default + config.support.latestAppVersion = "2.0.0" + let (vm, _, _) = make(info: info([sub()]), config: config, env: EnvironmentMock(appVersion: "1.0.0")) + await vm.load() + #expect(vm.showsUpdateBanner) + vm.continueAfterUpdateWarning() + #expect(!vm.showsUpdateBanner) + config.support.shouldWarnToUpdate = false + let (vm2, _, _) = make(info: info([sub()]), config: config, env: EnvironmentMock(appVersion: "1.0.0")) + await vm2.load() + #expect(!vm2.showsUpdateBanner) + } + + @Test("duplicate banner when App Store + web subs both active") + func duplicateBanner() async { + let (vm, _, _) = make(info: info([sub(), sub(store: .stripe)])) + await vm.load() + #expect(vm.showsDuplicateBanner) + } + + @Test("selecting a path with a survey: stores pending survey, presents sheet, no action yet") + func surveyFlow() async { + let tracker = EventTrackerMock() + let (vm, _, _) = make(info: info([sub()]), tracker: tracker) + await vm.load() + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + var selected: [CustomerCenterAction] = [] + vm.callbacks.didSelectAction = { action, _ in selected.append(action) } + var survey: (String, String, CustomerCenterAction)? + vm.callbacks.didCompleteSurvey = { survey = ($0, $1, $2) } + + await vm.select(manage, purchase: purchase) + #expect(selected == [.manageSubscription]) + #expect(vm.sheet == .survey(pathId: "manage_subscription")) + let hasActionEvent = tracker.events.contains { event in + if case .customerCenterAction(let action, let pathId, let productId) = event { + return action == .manageSubscription && pathId == "manage_subscription" && productId == "monthly" + } + return false + } + #expect(hasActionEvent) + + await vm.answerSurvey(optionId: "too_expensive") + #expect(survey?.0 == "cancel_survey" && survey?.1 == "too_expensive" && survey?.2 == .manageSubscription) + let hasSurveyEvent = tracker.events.contains { event in + if case .customerCenterSurveyResponse(let surveyId, let optionId, let action, let pathId, let productId) = event { + return surveyId == "cancel_survey" && optionId == "too_expensive" && action == .manageSubscription + && pathId == "manage_subscription" && productId == "monthly" + } + return false + } + #expect(hasSurveyEvent) + // The follow-up action is deferred: presenting the next sheet while the survey sheet is + // still animating out is silently dropped on iOS 15/16. + #expect(vm.sheet == nil) + + await vm.sheetDidDismiss() + #expect(vm.sheet == .manageSubscriptions(groupId: "g1")) + } + + @Test("cancelling the survey drops the pending action so a later dismissal performs nothing") + func cancelSurveyDropsPendingAction() async { + let (vm, _, _) = make(info: info([sub()])) + await vm.load() + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + await vm.select(manage, purchase: purchase) + #expect(vm.sheet == .survey(pathId: "manage_subscription")) + + vm.cancelSurvey() + #expect(vm.pendingSurvey == nil) + #expect(vm.sheet == nil) + + await vm.sheetDidDismiss() + #expect(vm.sheet == nil) + } + + @Test("restore: gate can cancel; success/notFound states; tracks via Superwall restore events (not duplicated here)") + func restoreFlow() async { + let restorer = RestorerMock() + let (vm, _, _) = make(info: info([]), restorer: restorer) + await vm.load() + vm.callbacks.shouldRestore = { resume in resume(false) } + await vm.performRestore() + #expect(restorer.calls == 0) + #expect(vm.restoreState == .idle) + + vm.callbacks.shouldRestore = nil + await vm.performRestore() + #expect(restorer.calls == 1) + #expect(vm.restoreState == .notFound) // info still has no purchases + + let (vm2, infoMock, _) = make(info: info([]), restorer: restorer) + await vm2.load() + infoMock.subject.value = info([sub()]) + await vm2.performRestore() + #expect(vm2.restoreState == .restored) + } + + @Test("restore: entitlement-only info (no local transactions) still counts as a purchase") + func restoreFlowEntitlementOnly() async { + let restorer = RestorerMock() + let (vm, infoMock, _) = make(info: info([]), restorer: restorer) + await vm.load() + #expect(vm.state == .noPurchases) + let entitlementOnlyInfo = CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [Entitlement(id: "premium")]) + infoMock.subject.value = entitlementOnlyInfo + await vm.performRestore() + #expect(vm.restoreState == .restored) + #expect(vm.state == .management) + } + + @Test("refund: select opens refund sheet with looked-up transaction id; finish records result + event + callback") + func refundFlow() async { + let lookup = StoreKitTransactionLookupMock(); lookup.transactionIDs["monthly"] = 42 + let tracker = EventTrackerMock() + let (vm, _, _) = make(info: info([sub()]), tracker: tracker, lookup: lookup) + await vm.load() + let purchase = vm.purchases[0] + let refund = vm.paths(for: purchase).first { $0.path.id == "refund" }! + await vm.select(refund, purchase: purchase) + #expect(vm.sheet == .refund(transactionId: 42, productId: "monthly")) + var completed: (String, CustomerCenterRefundStatus)? + vm.callbacks.didCompleteRefund = { completed = ($0, $1) } + await vm.refundSheetDidFinish(productId: "monthly", status: .success) + #expect(completed?.1 == .success) + #expect(vm.refundResult?.status == .success) + let hasRefundEvent = tracker.events.contains { event in + if case .customerCenterRefundRequest(let productId, let status) = event { + return productId == "monthly" && status == .success + } + return false + } + #expect(hasRefundEvent) + } + + @Test("url external → opener; url inApp → safari sheet; custom → callback only; contactSupport → mailto") + func urlCustomSupport() async { + let opener = URLOpenerMock() + let config = CustomerCenterConfiguration.default + config.support.email = "help@app.com" + let ext = URL(string: "https://a.b/ext")!, inApp = URL(string: "https://a.b/in")! + config.managementScreen.paths += [ + .init(id: "ext", type: .url(ext, title: "External", openMethod: .external)), + .init(id: "in", type: .url(inApp, title: "In app", openMethod: .inApp)), + .init(id: "c", type: .custom(identifier: "delete")) + ] + let (vm, _, _) = make(info: info([sub()]), config: config, opener: opener) + await vm.load() + var selected: [CustomerCenterAction] = [] + vm.callbacks.didSelectAction = { action, _ in selected.append(action) } + let paths = vm.paths(for: nil) + await vm.select(paths.first { $0.id == "ext" }!, purchase: nil) + #expect(opener.opened == [ext]) + await vm.select(paths.first { $0.id == "in" }!, purchase: nil) + #expect(vm.sheet == .safari(inApp)) + await vm.select(paths.first { $0.id == "c" }!, purchase: nil) + #expect(selected.last == .custom(identifier: "delete")) + await vm.select(paths.first { $0.id == "contact_support" }!, purchase: nil) + #expect(opener.opened.last?.scheme == "mailto") + } + + @Test("web sub manage → safari sheet with web management URL") + func webManage() async { + let url = URL(string: "https://x.superwall.app/manage")! + let (vm, _, _) = make(info: info([sub(store: .stripe)]), env: EnvironmentMock(webManagementURL: url)) + await vm.load() + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + vm.callbacks.didSelectAction = nil + // The default manage path carries a survey, but web flows skip it: the action leaves the app, + // so the answer would be gathered for something whose outcome is never observed. The handoff + // therefore happens on the first tap, with no survey in between. + await vm.select(manage, purchase: purchase) + #expect(vm.pendingSurvey == nil) + #expect(vm.sheet == .safari(url)) + } + + // MARK: - Contact support visibility + + @Test("contact support row shows even when canOpenURL is false; tap falls back to the address sheet") + func contactSupportVisibleWithoutCanOpen() async { + // On device `canOpenURL("mailto:")` is false unless the host app declares `mailto` in + // `LSApplicationQueriesSchemes`, so visibility must not depend on it. + let opener = URLOpenerMock() + opener.openable = false + let config = CustomerCenterConfiguration.default + config.support.email = "help@app.com" + let (vm, _, _) = make(info: info([sub()]), config: config, opener: opener) + await vm.load() + + let contact = vm.paths(for: nil).first { $0.path.id == "contact_support" } + #expect(contact != nil) + + await vm.select(contact!, purchase: nil) + #expect(opener.opened.isEmpty) + #expect(vm.sheet == .noMailApp(email: "help@app.com")) + } + + @Test("contact support row is hidden when no support email is configured") + func contactSupportHiddenWithoutEmail() async { + let (vm, _, _) = make(info: info([sub()])) // default config carries no support email + await vm.load() + #expect(!vm.paths(for: nil).contains { $0.path.id == "contact_support" }) + } + + // MARK: - Restore availability + + @Test("management screen still offers restore alongside a single subscription; detail screen doesn't") + func managementPathsIncludeRestore() async { + let (vm, _, _) = make(info: info([sub()])) + await vm.load() + let purchase = vm.purchases[0] + #expect(vm.paths(for: purchase).map(\.destination).contains(.restore)) + #expect(!vm.paths(for: purchase, isScreenLevel: false).map(\.destination).contains(.restore)) + } + + // MARK: - Receipt refresh on store-sheet dismissal + + @Test("change-plan sheet dismissal reloads from receipts rather than the cache") + func changePlanDismissalRefreshesReceipts() async { + let (vm, infoMock, _) = make(info: info([sub()])) + await vm.load() + let purchase = vm.purchases[0] + let change = vm.paths(for: purchase).first { $0.path.id == "change_plan" }! + await vm.select(change, purchase: purchase) + #expect(vm.sheet == .changePlan(groupId: "g1", productIds: nil)) + + await vm.sheetDidDismiss() + #expect(infoMock.didRefreshReceipts) + } + + @Test("manage-subscriptions dismissal refreshes receipts; a plain sheet dismissal does not") + func manageSubscriptionsDismissalRefreshesReceipts() async { + let (vm, infoMock, _) = make(info: info([sub()])) + await vm.load() + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + await vm.select(manage, purchase: purchase) + await vm.answerSurvey(optionId: "too_expensive") + + // Dismissing the *survey* sheet performs the deferred action but needs no receipt reload. + await vm.sheetDidDismiss() + #expect(vm.sheet == .manageSubscriptions(groupId: "g1")) + #expect(!infoMock.didRefreshReceipts) + + // Dismissing Apple's manage-subscriptions sheet does: cancelling auto-renew there emits no + // `Transaction.updates`, so a cached read would miss it. + await vm.sheetDidDismiss() + #expect(infoMock.didRefreshReceipts) + } + + // MARK: - Embedded navigation (visibility count) + + /// Tests below use a short debounce so they don't need real sleeps of `dismissDebounceInterval` + /// (the production default, 0.6s) to observe whether `dismiss()` fired. + func makeForVisibility(info customerInfo: CustomerInfo) -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock(info: customerInfo, products: ["monthly": monthly]) + return CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english, + isChangePlanSheetAvailable: true, + dismissDebounceInterval: 0.02 + ) + } + + /// Polls until `condition` holds or a generous timeout elapses. Under parallel test + /// execution the main actor can stall for tens of seconds (a 100ms sleep has been observed + /// taking 25s wall-clock), so tests wait on the outcome with a deadline that dwarfs the + /// congestion rather than sleeping a fixed wall-clock amount. Passing runs exit early. + func waitUntil(timeout: TimeInterval = 30, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } + } + + @Test("appear → disappear → after debounce, didDismiss fires exactly once") + func appearDisappearFiresOnce() async { + let vm = makeForVisibility(info: info([sub()])) + await vm.load() + var dismissCount = 0 + vm.callbacks.didDismiss = { dismissCount += 1 } + + vm.surfaceDidAppear() + vm.surfaceDidDisappear() + await waitUntil { dismissCount == 1 } + #expect(dismissCount == 1) + } + + @Test("appear → push (second appear) → first disappear → not dismissed while still inside") + func pushWithinCustomerCenterIsNotDismissal() async { + let vm = makeForVisibility(info: info([sub()])) + await vm.load() + var dismissed = false + vm.callbacks.didDismiss = { dismissed = true } + + // Root appears, then a pushed screen appears before the root disappears (embedded mode: + // both can be briefly on screen, or the push can register before the pop). + vm.surfaceDidAppear() + vm.surfaceDidAppear() + vm.surfaceDidDisappear() + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(!dismissed) + + // The second surface disappearing too means the Customer Center is genuinely gone. + vm.surfaceDidDisappear() + await waitUntil { dismissed } + #expect(dismissed) + } + + @Test("two-deep push: three appears then three disappears fires exactly once") + func twoDeepPushFiresOnce() async { + let vm = makeForVisibility(info: info([sub()])) + await vm.load() + var dismissCount = 0 + vm.callbacks.didDismiss = { dismissCount += 1 } + + vm.surfaceDidAppear() + vm.surfaceDidAppear() + vm.surfaceDidAppear() + vm.surfaceDidDisappear() + vm.surfaceDidDisappear() + vm.surfaceDidDisappear() + await waitUntil { dismissCount == 1 } + #expect(dismissCount == 1) + } + + @Test("debounced dismissal still fires after the owner releases the view model (sheet teardown)") + func debounceSurvivesOwnerRelease() async { + var vm: CustomerCenterViewModel? = makeForVisibility(info: info([sub()])) + await vm?.load() + var dismissCount = 0 + vm?.callbacks.didDismiss = { dismissCount += 1 } + + vm?.surfaceDidAppear() + vm?.surfaceDidDisappear() + // SwiftUI releases the @StateObject right after the sheet's last onDisappear; the pending + // debounce must keep the model alive long enough to deliver didDismiss and track the close. + vm = nil + await waitUntil { dismissCount == 1 } + #expect(dismissCount == 1) + } + + @Test("dismiss() remains idempotent when reached via the debounce and called again directly") + func dismissRemainsIdempotent() async { + let vm = makeForVisibility(info: info([sub()])) + await vm.load() + var dismissCount = 0 + vm.callbacks.didDismiss = { dismissCount += 1 } + + vm.surfaceDidAppear() + vm.surfaceDidDisappear() + await waitUntil { dismissCount == 1 } + #expect(dismissCount == 1) + + // A stray extra disappear (or a direct call) after the debounce already fired must not + // double-fire the callback or the close event. + vm.surfaceDidDisappear() + vm.dismiss() + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(dismissCount == 1) + } + + @Test("dismiss tracks close and calls back; publisher updates re-render") + func dismissAndPublisher() async { + let tracker = EventTrackerMock() + let (vm, infoMock, _) = make(info: info([]), tracker: tracker) + await vm.load() + #expect(vm.state == .noPurchases) + infoMock.subject.value = info([sub()]) + await waitUntil { vm.state == .management } + #expect(vm.state == .management) + var dismissed = false + vm.callbacks.didDismiss = { dismissed = true } + vm.dismiss() + #expect(dismissed) + func trackedClose() -> Bool { + if case .customerCenterClose = tracker.events.last { return true } + return false + } + await waitUntil { trackedClose() } + #expect(trackedClose()) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift new file mode 100644 index 0000000000..27ca71b6bb --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift @@ -0,0 +1,147 @@ +// +// PurchaseDetailActionsTests.swift +// +// +// Created by Jordan Morgan on 11/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +/// Every subscription row opens its detail screen. These pin what that screen has to say once it +/// is open, driven from the shipped `.default` configuration — no support email, no web +/// management URL — because that is the configuration under which several realistic purchases +/// resolve to no actions at all. What the screen says then depends on *why* there are none: a +/// customer still paying for a subscription this SDK can't drive must be told where to manage +/// it, never that there is nothing to manage. +@Suite("Purchase detail: actions, or the right explanation") +@MainActor +struct PurchaseDetailActionsTests { + typealias EmptyState = CustomerCenterViewModel.DetailEmptyState + + private func subscription( + store: ProductStore = .appStore, + isActive: Bool = true, + isRevoked: Bool = false + ) -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: "t1", + productId: "pro_monthly", + purchaseDate: Date().addingTimeInterval(-30 * 86_400), + willRenew: isActive, + isRevoked: isRevoked, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: isActive, + expirationDate: Date().addingTimeInterval(isActive ? 12 * 86_400 : -86_400), + subscriptionGroupId: "group_pro", + store: store + ) + } + + @available(iOS 15.0, *) + private func makeViewModel( + subscriptions: [SubscriptionTransaction] = [], + entitlements: [Entitlement] = [] + ) async -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: subscriptions, nonSubscriptions: [], entitlements: entitlements) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + return viewModel + } + + @available(iOS 15.0, *) + private func emptyState(_ viewModel: CustomerCenterViewModel) throws -> EmptyState? { + let purchase = try #require(viewModel.purchases.first, "the row exists — nothing is hidden") + return viewModel.detailEmptyState(for: purchase) + } + + // MARK: Genuinely nothing to do + + @available(iOS 15.0, *) + @Test("a revoked App Store subscription: nothing to do") + func revokedAppStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(isRevoked: true)]) + #expect(try emptyState(viewModel) == .nothingToDo) + } + + @available(iOS 15.0, *) + @Test("a lapsed web subscription: nothing to do") + func lapsedWeb() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe, isActive: false)]) + #expect(try emptyState(viewModel) == .nothingToDo) + } + + @available(iOS 15.0, *) + @Test("a comped grant with no management page: nothing to do") + func compedGrant() async throws { + let viewModel = await makeViewModel(entitlements: [Entitlement(id: "pro", store: nil)]) + #expect(try emptyState(viewModel) == .nothingToDo) + } + + /// A lifetime grant from a store this SDK can't drive is not "managed elsewhere": nothing + /// renews, so there is nothing to manage anywhere, and the card's "Lifetime" badge would sit + /// over a sentence about a subscription. Liveness alone isn't the test; renewal is. + @available(iOS 15.0, *) + @Test("a lifetime Play Store grant: nothing to do, not a subscription to manage elsewhere") + func lifetimePlayStoreGrant() async throws { + let lifetime = Entitlement(id: "pro", isActive: true, store: .playStore, isLifetime: true) + let viewModel = await makeViewModel(entitlements: [lifetime]) + let purchase = try #require(viewModel.purchases.first) + #expect(purchase.badge == .lifetime, "the shape under test") + #expect(viewModel.detailEmptyState(for: purchase) == .nothingToDo) + } + + // MARK: Still paying, but not here + + /// The shape the last review caught: an active Play Store subscription on an iOS client. Its + /// card reads "Active — renews on …", so "nothing to manage" would be a lie. The store has a + /// name, and the sentence uses it. + @available(iOS 15.0, *) + @Test("a live Play Store subscription is managed elsewhere, by name") + func livePlayStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .playStore)]) + #expect(try emptyState(viewModel) == .managedElsewhere(storeLabelKey: "customer_center_store_google_play")) + } + + @available(iOS 15.0, *) + @Test("a live subscription from an unnamed store is managed elsewhere, generically") + func liveOtherStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .other)]) + #expect(try emptyState(viewModel) == .managedElsewhere(storeLabelKey: nil)) + } + + /// A lapsed Play Store subscription is not "managed elsewhere" — there is nothing left to + /// manage anywhere. Liveness, not store, decides between the two sentences. + @available(iOS 15.0, *) + @Test("a lapsed Play Store subscription: nothing to do") + func lapsedPlayStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .playStore, isActive: false)]) + #expect(try emptyState(viewModel) == .nothingToDo) + } + + // MARK: Has actions, so no explanation at all + + @available(iOS 15.0, *) + @Test("an active App Store subscription has actions") + func activeAppStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription()]) + #expect(try emptyState(viewModel) == nil) + } + + /// A live web subscription with no management URL still gets the row that explains where the + /// link is — fixed once already, and this keeps it from being read as an empty state. + @available(iOS 15.0, *) + @Test("an active web subscription has the management row even with no URL") + func activeWeb() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe)]) + #expect(try emptyState(viewModel) == nil) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/AccentColorRoundTripTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/AccentColorRoundTripTests.swift new file mode 100644 index 0000000000..d538a7febd --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/AccentColorRoundTripTests.swift @@ -0,0 +1,32 @@ +// +// AccentColorRoundTripTests.swift +// SuperwallKit +// +// Created by Jordan Morgan on 21/08/2026. +// + +import Testing +import UIKit +@testable import SuperwallKit + +@Suite("Appearance accent round trip") +struct AccentColorRoundTripTests { + @Test("a UIColor accent survives the hex round trip into a usable Color") + func systemColorRoundTrip() { + let pair = CustomerCenterConfiguration.Appearance.ColorPair( + light: .systemPurple, + dark: .systemTeal + ) + + let parsedLight = UIColor(hex: pair.light) + let parsedDark = UIColor(hex: pair.dark) + + #expect(parsedLight != nil, "light hex \(pair.light) failed to parse") + #expect(parsedDark != nil, "dark hex \(pair.dark) failed to parse") + + // And through the theme the views actually read. + let appearance = CustomerCenterConfiguration.Appearance(accent: pair) + let lightTheme = CustomerCenterTheme(appearance: appearance, colorScheme: .light) + #expect(lightTheme.accent != nil, "theme produced no accent colour") + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift new file mode 100644 index 0000000000..2e600a4a85 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -0,0 +1,274 @@ +// +// CustomerCenterSheetOwnershipTests.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import Testing +import Foundation +import SwiftUI +import UIKit +@testable import SuperwallKit + +/// When the host owns the navigation, the Customer Center's screens are separate hosting +/// controllers and every one of them applies the sheet modifiers. Only the screen the user is +/// actually looking at may present, or two controllers race for the same sheet. +/// +/// These drive `CustomerCenterPushNavigator` and the controllers it pushes rather than restating +/// their arithmetic — an earlier version of this file re-implemented the rules locally and passed +/// while the real gate presented nothing at all. +@Suite("Customer Center sheet ownership", .serialized) +@MainActor +struct CustomerCenterSheetOwnershipTests { + private final class ProbeDelegate: CustomerCenterDelegate { + var didDismissCount = 0 + func customerCenterDidDismiss() { didDismissCount += 1 } + } + + @available(iOS 15.0, *) + private func makeViewModel( + delegate: CustomerCenterDelegate? = nil, + dismissDebounceInterval: TimeInterval = 0.6 + ) -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english, + dismissDebounceInterval: dismissDebounceInterval + ) + if let delegate { + viewModel.callbacks = CustomerCenterDelegateAdapter( + swiftDelegate: delegate, + objcDelegate: nil + ).makeCallbacks() + } + return viewModel + } + + private func makeWindow(rootViewController: UIViewController) -> UIWindow { + let window: UIWindow + if let scene = UIApplication.sharedApplication?.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: scene) + window.frame = scene.screen.bounds + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + window.rootViewController = rootViewController + return window + } + + private func spinRunLoop(timeout: TimeInterval, until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } + + /// The rule the sheet modifier applies, exercised directly. + @Test("the root owns sheets until something is pushed over it") + func ownershipFollowsDepth() { + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: 0)) + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: 1)) + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 1, pushDepth: 1)) + // A surface deeper than the current depth is stale and must not present either. + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 2, pushDepth: 1)) + } + + /// The asymmetry that made this rule necessary: SwiftUI writes `false` to a boolean sheet + /// binding whenever its getter goes false, which happens to every surface the moment something + /// is pushed over it — not only when that surface's own sheet is dismissed. An unconditional + /// clear therefore let a covered screen tear down the sheet the visible one had just opened, + /// and run `sheetDidDismiss()` against it. + @Test("only the sheet that is actually up may clear itself", arguments: [ + (CustomerCenterSheet.manageSubscriptions(groupId: nil), true, false), + (CustomerCenterSheet.refund(transactionId: 1, productId: "monthly_pro"), false, true), + (CustomerCenterSheet.survey(pathId: "cancel"), false, false), + (CustomerCenterSheet.webManageUnavailable, false, false) + ]) + func dismissalOnlyClearsItsOwnSheet( + current: CustomerCenterSheet, + clearsManage: Bool, + clearsRefund: Bool + ) { + #expect(CustomerCenterSheetOwnership.dismissalClears(current, .manageSubscriptions) == clearsManage) + #expect(CustomerCenterSheetOwnership.dismissalClears(current, .refund) == clearsRefund) + } + + @Test("a dismissal with no sheet up clears nothing") + func dismissalWithNothingPresentedClearsNothing() { + #expect(!CustomerCenterSheetOwnership.dismissalClears(nil, .manageSubscriptions)) + #expect(!CustomerCenterSheetOwnership.dismissalClears(nil, .refund)) + } + + /// The two above prove the rule; this proves the binding applies it. Testing only the rule + /// would pass just as happily against the unguarded setter that made it necessary. + @available(iOS 15.0, *) + @Test("a stale dismissal write does not tear down another surface's sheet") + func staleDismissalLeavesTheOpenSheetAlone() async { + let viewModel = makeViewModel() + let modifier = CustomerCenterSheetsModifier(viewModel: viewModel, surfaceDepth: 0) + + // A drill-down opens the refund sheet. The root's manage binding is still alive underneath. + viewModel.sheet = .refund(transactionId: 1, productId: "monthly_pro") + #expect(!modifier.isManagePresented.wrappedValue, "the manage sheet is not the one showing") + + // SwiftUI writes `false` into it, as it does to every binding whose getter goes false. + modifier.isManagePresented.wrappedValue = false + + #expect( + viewModel.sheet == .refund(transactionId: 1, productId: "monthly_pro"), + "the refund sheet must survive a dismissal meant for a sheet that was never up" + ) + + // And the binding that does own the sheet still clears it. + modifier.refundBinding.wrappedValue = false + #expect(viewModel.sheet == nil) + + // The owner keeps that right once it is no longer topmost. The depth drops on a pop with no + // regard for whether a sheet is up, which is why the setters are gated on identity rather + // than depth — an earlier depth-gated setter is exactly what stranded a sheet here. + viewModel.pushDepth = 1 + viewModel.sheet = .manageSubscriptions(groupId: nil) + modifier.isManagePresented.wrappedValue = false + #expect(viewModel.sheet == nil, "the surface that opened a sheet must always be able to clear it") + } + + // MARK: - Driving the real navigator + + @available(iOS 15.0, *) + @Test("pushing a drill-down takes ownership, popping it hands ownership back") + func pushAndPopMoveOwnership() { + let viewModel = makeViewModel() + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + + navigator.push(Text("purchase history")) + // Wait for the pushed screen to actually be on screen: UIKit only reports a removal for a + // controller that appeared, so asserting on `viewControllers.count` alone would test a + // controller that never lived. + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + #expect(viewModel.pushDepth == 1) + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: viewModel.pushDepth)) + + navigation.popViewController(animated: false) + spinRunLoop(timeout: 1) { viewModel.pushDepth == 0 } + #expect(viewModel.pushDepth == 0, "the root must be able to present again") + + window.isHidden = true + } + + /// Two screens popped at once are both removed and UIKit doesn't promise which reports first. + /// Driven through the real controllers rather than by restating the navigator's arithmetic. + @available(iOS 15.0, *) + @Test("popping two drill-downs at once does not strand ownership") + func poppingTwoAtOnceDoesNotStrand() { + let viewModel = makeViewModel() + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + // The second push comes from the screen that was just pushed. + navigator.presenter = navigation.viewControllers.last + navigator.push(Text("purchase detail")) + spinRunLoop(timeout: 2) { navigation.viewControllers.count == 3 } + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + #expect(viewModel.pushDepth == 2) + + navigation.popToRootViewController(animated: false) + spinRunLoop(timeout: 1) { viewModel.pushDepth == 0 } + + #expect(viewModel.pushDepth == 0) + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: viewModel.pushDepth)) + + window.isHidden = true + } + + /// A pushed screen being covered is not a teardown, and the debounce must be vetoed there just + /// as it is on the root controller — otherwise `didDismiss` latches and the real teardown is + /// silent. + @available(iOS 15.0, *) + @Test("covering a drill-down does not deliver a dismissal") + func coveringADrillDownDoesNotDismiss() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let viewModel = makeViewModel(delegate: delegate, dismissDebounceInterval: debounce) + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + + // The drill-down reports the disappearance SwiftUI uses to arm the debounce… + viewModel.surfaceDidDisappear() + // …and the host covers it with its own screen rather than popping it. + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { navigation.viewControllers.count == 3 } + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 0, "being covered is not being torn down") + + window.isHidden = true + } + + /// UIKit marks the controller it is directly removing, not the screens inside it. When a host + /// presents a navigation controller holding a pushed Customer Center and dismisses it while the + /// user is on a drill-down, nothing on the drill-down itself is set — so a check of `self` alone + /// reads a real teardown as a cover, vetoes the debounce, and delivers no dismissal at all. The + /// root underneath is covered, so it never gets a `viewDidDisappear` of its own to correct it. + @available(iOS 15.0, *) + @Test("dismissing a container the Customer Center sits inside is a teardown, not a cover") + func dismissingTheContainingNavigationControllerDismisses() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let viewModel = makeViewModel(delegate: delegate, dismissDebounceInterval: debounce) + let host = UIViewController() + let navigation = DismissingNavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + + // The drill-down disappears because the container around it is being dismissed. SwiftUI has + // armed the debounce; nothing must cancel it. + viewModel.surfaceDidDisappear() + navigation.viewControllers.last?.viewDidDisappear(false) + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 1, "a dismissed container is a dismissed Customer Center") + + window.isHidden = true + } +} + +/// Stands in for a navigation controller the host has presented and is now dismissing. +/// `isBeingDismissed` is read-only, and a hostless test target never drives a modal transition to +/// completion, so the one fact UIKit would report is supplied directly. +private final class DismissingNavigationController: UINavigationController { + override var isBeingDismissed: Bool { true } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift new file mode 100644 index 0000000000..653e88d2fc --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -0,0 +1,201 @@ +// +// CustomerCenterViewSmokeTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Testing +import SwiftUI +@testable import SuperwallKit + +@Suite("CustomerCenterView smoke") +@MainActor +struct CustomerCenterViewSmokeTests { + @Test("hosts without crashing in management and no-active states and exposes accessibility ids") + @available(iOS 15.0, *) + func hosts() async throws { + let now = Date() + let sub = SubscriptionTransaction( + transactionId: "t", + productId: "monthly", + purchaseDate: now, + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: now.addingTimeInterval(86_400), + offerType: nil, + subscriptionGroupId: "g", + store: .appStore + ) + for info in [ + CustomerInfo(subscriptions: [sub], nonSubscriptions: [], entitlements: []), + CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ] { + let (deps, _, _) = CustomerCenterDependencies.mock(info: info) + let model = CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + await model.load() + let host = UIHostingController(rootView: CustomerCenterView(viewModel: model, navigationOptions: .default)) + host.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + // A hosting controller only materializes its SwiftUI-backed subviews (e.g. `List`'s + // internal UICollectionView) once it's part of a real window hierarchy — `loadViewIfNeeded()` + // plus `layoutIfNeeded()` alone isn't enough to drive that pass in a headless test. + let window = UIWindow(frame: host.view.frame) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + #expect(host.view.subviews.isEmpty == false) + window.isHidden = true + } + } + + @Test("survey and history views host") + @available(iOS 15.0, *) + func secondaryViews() async { + let now = Date() + let sub = SubscriptionTransaction( + transactionId: "t", + productId: "monthly", + purchaseDate: now, + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: now.addingTimeInterval(86_400), + offerType: nil, + subscriptionGroupId: "g", + store: .appStore + ) + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [sub], nonSubscriptions: [], entitlements: []) + ) + let vm = CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + await vm.load() + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + await vm.select(manage, purchase: purchase) + let detail = PurchaseDetailScreenView(viewModel: vm, purchase: purchase) + for view in [AnyView(FeedbackSurveyView(viewModel: vm)), AnyView(detail)] { + let host = UIHostingController(rootView: view) + host.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let window = UIWindow(frame: host.view.frame) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + #expect(!host.view.subviews.isEmpty) + window.isHidden = true + } + } + + @Test("presentSuperwallCustomerCenter(isPresented:) compiles and hosts") + @available(iOS 15.0, *) + func presentCustomerCenterHosts() { + struct Host: View { + @State var isPresented = true + var body: some View { + NavigationView { Text("Root") }.presentSuperwallCustomerCenter(isPresented: $isPresented) + } + } + let host = UIHostingController(rootView: Host()) + host.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let window = UIWindow(frame: host.view.frame) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + #expect(!host.view.subviews.isEmpty) + window.isHidden = true + } + + @Test("onCustomerCenterAction applied outside CustomerCenterView merges into the view model and fires on selection") + @available(iOS 15.0, *) + func environmentCallbackMergesAndFires() async throws { + let now = Date() + let sub = SubscriptionTransaction( + transactionId: "t", + productId: "monthly", + purchaseDate: now, + willRenew: true, + isRevoked: false, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: true, + expirationDate: now.addingTimeInterval(86_400), + offerType: nil, + subscriptionGroupId: "g", + store: .appStore + ) + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [sub], nonSubscriptions: [], entitlements: []) + ) + let vm = CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + await vm.load() + + final class ReceivedBox: @unchecked Sendable { + var action: CustomerCenterAction? + var transaction: SubscriptionTransaction? + } + let received = ReceivedBox() + + let view = CustomerCenterView(viewModel: vm, navigationOptions: .default) + .onCustomerCenterAction { action, transaction in + received.action = action + received.transaction = transaction + } + let host = UIHostingController(rootView: view) + host.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) + let window = UIWindow(frame: host.view.frame) + window.rootViewController = host + window.makeKeyAndVisible() + host.view.layoutIfNeeded() + + // `.task` runs asynchronously once the hosted view is part of a real window hierarchy; + // give the run loop a few turns to let it fire and merge the environment callback in. + for _ in 0..<20 where vm.callbacks.didSelectAction == nil { + try await Task.sleep(nanoseconds: 20_000_000) + } + #expect(vm.callbacks.didSelectAction != nil) + + let purchase = vm.purchases[0] + let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! + await vm.select(manage, purchase: purchase) + + #expect(received.action == .manageSubscription) + #expect(received.transaction?.transactionId == "t") + window.isHidden = true + } + + @Test("merged prefers the environment's non-nil closures and keeps un-overridden ones") + @available(iOS 15.0, *) + func mergedHelperSemantics() { + var existing = CustomerCenterCallbacks() + var existingRestoreCalled = false + var existingDismissCalled = false + existing.shouldRestore = { _ in existingRestoreCalled = true } + existing.didDismiss = { existingDismissCalled = true } + + var environment = CustomerCenterCallbacks() + var envSelectCalled = false + environment.didSelectAction = { _, _ in envSelectCalled = true } + + let result = CustomerCenterView.merged(existing, environment) + + // Field only set on `existing` survives untouched. + result.didDismiss?() + #expect(existingDismissCalled) + + // Field only set on `environment` is present in the result. + result.didSelectAction?(.restore, nil) + #expect(envSelectCalled) + + // Field set on `existing` but not `environment` still comes from `existing`. + result.shouldRestore?({ _ in }) + #expect(existingRestoreCalled) + + // Field unset on both stays nil. + #expect(result.didCompleteSurvey == nil) + #expect(result.didCompleteRefund == nil) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift new file mode 100644 index 0000000000..f4482d1dd0 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -0,0 +1,683 @@ +// +// DesignReviewSnapshots.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// +// Renders the Customer Center's screens across the customer states and configurations a +// designer needs to review, and writes them to disk as PNGs. +// +// Not part of the suite's verification — it asserts nothing about behaviour. It is dormant +// unless `CUSTOMER_CENTER_SNAPSHOT_DIR` is set, which has to happen on the scheme: tick +// `CUSTOMER_CENTER_SNAPSHOT_DIR` under Edit Scheme → Test → Arguments and point it at a +// directory, then run `-only-testing:SuperwallKitTests/DesignReviewSnapshots`. +// +// Setting it on the command line does not work, in either form — neither +// `CUSTOMER_CENTER_SNAPSHOT_DIR=... xcodebuild test` nor xcodebuild's `TEST_RUNNER_` prefix +// reaches the test process running in the simulator, and the suite silently skips. +// +// The run ends by recording an issue naming the file count. That is the report, not a failure; +// a passing run here would mean nothing was written. +// + +import Testing +import Foundation +import SwiftUI +import UIKit +@testable import SuperwallKit + +/// Where the PNGs go, or `nil` when the suite should stay dormant. A free function rather than a +/// static on the suite: a trait cannot reference the very type the `@Suite` macro is expanding. +private func customerCenterSnapshotDirectory() -> URL? { + guard let raw = ProcessInfo.processInfo.environment["CUSTOMER_CENTER_SNAPSHOT_DIR"], + !raw.isEmpty else { + return nil + } + return URL(fileURLWithPath: (raw as NSString).expandingTildeInPath) +} + +@Suite("Design review snapshots", .serialized, .enabled(if: customerCenterSnapshotDirectory() != nil)) +@MainActor +struct DesignReviewSnapshots { + static var outputDirectory: URL? { customerCenterSnapshotDirectory() } + + // MARK: - Fixtures + + private static let now = Date() + private static let day: TimeInterval = 86_400 + + private func subscription( + productId: String = "monthly_pro", + transactionId: String = "t1", + purchaseDate: TimeInterval = -30, + willRenew: Bool = true, + isRevoked: Bool = false, + isInGracePeriod: Bool = false, + isInBillingRetryPeriod: Bool = false, + isActive: Bool = true, + expiresIn: TimeInterval? = 12, + offerType: LatestSubscription.OfferType? = nil, + groupId: String? = "group_pro", + store: ProductStore = .appStore + ) -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: transactionId, + productId: productId, + purchaseDate: Self.now.addingTimeInterval(purchaseDate * Self.day), + willRenew: willRenew, + isRevoked: isRevoked, + isInGracePeriod: isInGracePeriod, + isInBillingRetryPeriod: isInBillingRetryPeriod, + isActive: isActive, + expirationDate: expiresIn.map { Self.now.addingTimeInterval($0 * Self.day) }, + offerType: offerType, + subscriptionGroupId: groupId, + store: store + ) + } + + private func nonSubscription( + productId: String = "lifetime_pro", + transactionId: String = "n1", + purchaseDate: TimeInterval = -120, + isConsumable: Bool = false, + isRevoked: Bool = false + ) -> NonSubscriptionTransaction { + NonSubscriptionTransaction( + transactionId: transactionId, + productId: productId, + purchaseDate: Self.now.addingTimeInterval(purchaseDate * Self.day), + isConsumable: isConsumable, + isRevoked: isRevoked, + store: .appStore + ) + } + + private var catalogue: [String: ProductDisplayInfo] { + [ + "monthly_pro": .init( + productId: "monthly_pro", + title: "Pro Monthly", + localizedPrice: "$9.99", + price: 9.99, + localizedPeriod: "month", + subscriptionGroupId: "group_pro", + isAutoRenewable: true + ), + "annual_pro": .init( + productId: "annual_pro", + title: "Pro Annual", + localizedPrice: "$79.99", + price: 79.99, + localizedPeriod: "year", + subscriptionGroupId: "group_pro", + isAutoRenewable: true + ), + "coach_monthly": .init( + productId: "coach_monthly", + title: "Coaching Add-on", + localizedPrice: "$4.99", + price: 4.99, + localizedPeriod: "month", + subscriptionGroupId: "group_coach", + isAutoRenewable: true + ), + "lifetime_pro": .init( + productId: "lifetime_pro", + title: "Lifetime Unlock", + localizedPrice: "$149.99", + price: 149.99, + localizedPeriod: nil, + subscriptionGroupId: nil, + isAutoRenewable: false + ), + "coins_500": .init( + productId: "coins_500", + title: "500 Coins", + localizedPrice: "$0.99", + price: 0.99, + localizedPeriod: nil, + subscriptionGroupId: nil, + isAutoRenewable: false + ), + // A web product as it arrives today: the catalogue supplies its price but `/v1/products` + // carries no display name, so the card shows no title (the fixture's `CustomerInfo` carries + // no entitlement for it either — with one, that entitlement would head the card instead). + "web_pro_monthly": .init( + productId: "web_pro_monthly", + title: nil, + localizedPrice: "$12.99", + price: 12.99, + localizedPeriod: "month", + subscriptionGroupId: nil, + isAutoRenewable: true + ), + "extra_theme": .init( + productId: "extra_theme", + title: "Midnight Theme", + localizedPrice: "$1.99", + price: 1.99, + localizedPeriod: nil, + subscriptionGroupId: nil, + isAutoRenewable: false + ) + ] + } + + /// The configuration a developer gets with no setup at all, plus a support email, since the + /// contact-support row is hidden without one and the designer needs to see it. + private func defaultConfiguration() -> CustomerCenterConfiguration { + let configuration = CustomerCenterConfiguration.default + configuration.support.email = "support@acme.com" + return configuration + } + + private func cancellationSurvey() -> CustomerCenterConfiguration.FeedbackSurvey { + .init( + id: "cancel_survey", + title: "Why are you cancelling?", + options: [ + .init(id: "too_expensive", title: "It's too expensive"), + .init(id: "dont_use", title: "I don't use it enough"), + .init(id: "missing_features", title: "Missing features I need"), + .init(id: "switched", title: "I switched to something else"), + .init(id: "other", title: "Another reason") + ] + ) + } + + // MARK: - Rendering + + private func makeViewModel( + subscriptions: [SubscriptionTransaction] = [], + nonSubscriptions: [NonSubscriptionTransaction] = [], + entitlements: [Entitlement] = [], + configuration: CustomerCenterConfiguration? = nil, + environment: EnvironmentMock = EnvironmentMock(), + familyShared: Set = [] + ) async -> CustomerCenterViewModel { + // Family sharing is the one gating input that isn't in `CustomerInfo` — it comes from a + // StoreKit transaction lookup — so it has to be faked separately. + let lookup = StoreKitTransactionLookupMock() + lookup.familyShared = familyShared + // `EnvironmentMock` defaults this to the Unix epoch, which renders as "December 31, 1969" in + // Account details on nearly every screen. Harmless in a unit test, but in a design review it + // reads as a bug and costs the room a conversation. Give it a plausible install date. + var environment = environment + if environment.originalDownloadDate == Date(timeIntervalSince1970: 0) { + environment.originalDownloadDate = Self.now.addingTimeInterval(-400 * 24 * 60 * 60) + } + let (dependencies, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: nonSubscriptions, + entitlements: entitlements + ), + products: catalogue, + environment: environment, + lookup: lookup + ) + let viewModel = CustomerCenterViewModel( + configuration: configuration ?? defaultConfiguration(), + dependencies: dependencies, + strings: .english + ) + await viewModel.load() + return viewModel + } + + /// Hosts `view` in a window at iPhone dimensions and writes a PNG. + private func snapshot( + _ view: V, + named name: String, + colorScheme: ColorScheme, + directory: URL + ) { + let host = UIHostingController(rootView: view.preferredColorScheme(colorScheme)) + host.overrideUserInterfaceStyle = colorScheme == .dark ? .dark : .light + + let window: UIWindow + if let scene = UIApplication.sharedApplication?.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: scene) + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + let size = window.bounds.size + host.view.frame = CGRect(origin: .zero, size: size) + window.overrideUserInterfaceStyle = host.overrideUserInterfaceStyle + window.rootViewController = host + window.makeKeyAndVisible() + + // Let SwiftUI settle: `.task`/`onAppear` work and List layout land a runloop turn or two after + // the view is installed, and a capture taken too early shows an empty or half-laid-out screen. + host.view.setNeedsLayout() + host.view.layoutIfNeeded() + for _ in 0..<8 { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + host.view.layoutIfNeeded() + + // `layer.render` rather than `drawHierarchy`: this bundle runs with no window scene attached, + // so there is no render server for `drawHierarchy` to snapshot and it yields a blank fill. + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + let renderer = UIGraphicsImageRenderer(size: size, format: format) + let image = renderer.image { context in + window.layer.render(in: context.cgContext) + } + let suffix = colorScheme == .dark ? "dark" : "light" + let url = directory.appendingPathComponent("\(name)-\(suffix).png") + if let data = image.pngData() { + try? data.write(to: url) + } + window.isHidden = true + } + + private func capture( + _ name: String, + directory: URL, + viewModel: CustomerCenterViewModel + ) { + for scheme in [ColorScheme.light, .dark] { + snapshot( + CustomerCenterView(viewModel: viewModel, navigationOptions: .default), + named: name, + colorScheme: scheme, + directory: directory + ) + } + } + + /// Captures a screen the user drills into, wrapped in its own navigation so it renders with the + /// title bar the designer would see. + private func captureDetail( + _ name: String, + directory: URL, + viewModel: CustomerCenterViewModel, + @ViewBuilder content: () -> V + ) { + let view = NavigationView { content() } + .navigationViewStyle(.stack) + .environment(\.customerCenterStrings, viewModel.strings) + .environment( + \.customerCenterTheme, + CustomerCenterTheme(appearance: viewModel.configuration.appearance, colorScheme: .light) + ) + for scheme in [ColorScheme.light, .dark] { + snapshot(view, named: name, colorScheme: scheme, directory: directory) + } + } + + /// Captures the content of a `.sheet`. Deliberately not wrapped in a `NavigationView`: a page + /// sheet has no bar unless its content supplies one, so wrapping it would paint a title bar + /// production never shows — and hide the very thing the screenshot exists to reveal. + private func captureSheet( + _ name: String, + directory: URL, + viewModel: CustomerCenterViewModel, + @ViewBuilder content: () -> V + ) { + let view = content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground)) + .environment(\.customerCenterStrings, viewModel.strings) + for scheme in [ColorScheme.light, .dark] { + snapshot(view, named: name, colorScheme: scheme, directory: directory) + } + } + + // MARK: - The screens + + @available(iOS 15.0, *) + @Test("render every Customer Center state for design review") + func renderAll() async throws { + let directory = try #require(Self.outputDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + // 1. Nothing purchased — the empty state. + capture("01-no-purchases", directory: directory, viewModel: await makeViewModel()) + + // 2. One active auto-renewing subscription. Its row opens the detail screen, which is where + // the subscription's own actions live. + capture( + "02-active-subscription", + directory: directory, + viewModel: await makeViewModel(subscriptions: [subscription()]) + ) + + // 3. Active, but the user has already cancelled — still entitled until the period ends. + capture( + "03-cancelled-still-active", + directory: directory, + viewModel: await makeViewModel(subscriptions: [subscription(willRenew: false, expiresIn: 9)]) + ) + + // 4. Payment failed and Apple is retrying. The state most worth designing for. + capture( + "04-billing-retry", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription(isInBillingRetryPeriod: true, expiresIn: 2)] + ) + ) + + // 5. In grace period — still entitled while Apple retries. + capture( + "05-grace-period", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription(isInGracePeriod: true, expiresIn: 3)] + ) + ) + + // 6. Lapsed. + capture( + "06-expired-subscription", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [ + subscription(purchaseDate: -400, willRenew: false, isActive: false, expiresIn: -30) + ] + ) + ) + + // 7. Refunded / revoked by Apple. + capture( + "07-revoked-subscription", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription(isRevoked: true, isActive: false, expiresIn: -5)] + ) + ) + + // 8. Free trial. + capture( + "08-free-trial", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription(purchaseDate: -3, expiresIn: 4, offerType: .trial)] + ) + ) + + // 9. Several subscriptions at once — the list layout, where each row drills in. + capture( + "09-multiple-subscriptions", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [ + subscription(), + subscription( + productId: "coach_monthly", + transactionId: "t2", + purchaseDate: -10, + groupId: "group_coach" + ) + ] + ) + ) + + // 10. A subscription plus one-off purchases. + capture( + "10-subscription-and-purchases", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription()], + nonSubscriptions: [ + nonSubscription(), + nonSubscription(productId: "extra_theme", transactionId: "n2", purchaseDate: -60) + ] + ) + ) + + // 11. Non-subscription purchases only. + capture( + "11-lifetime-only", + directory: directory, + viewModel: await makeViewModel(nonSubscriptions: [nonSubscription()]) + ) + + // 12. A subscription alongside several one-off purchases, every one of them shown. + let manyPurchases = await makeViewModel( + subscriptions: [subscription()], + nonSubscriptions: [ + nonSubscription(), + nonSubscription(productId: "extra_theme", transactionId: "n2", purchaseDate: -60), + nonSubscription(productId: "coins_500", transactionId: "n3", purchaseDate: -20, isConsumable: true), + nonSubscription(productId: "coins_500", transactionId: "n4", purchaseDate: -8, isConsumable: true) + ] + ) + capture("12-many-purchases", directory: directory, viewModel: manyPurchases) + + // 14. The per-purchase detail screen, reached from the multi-subscription list. + let multi = await makeViewModel( + subscriptions: [ + subscription(), + subscription( + productId: "coach_monthly", + transactionId: "t2", + purchaseDate: -10, + groupId: "group_coach" + ) + ] + ) + if let purchase = multi.purchases.first { + captureDetail("14-purchase-detail", directory: directory, viewModel: multi) { + PurchaseDetailScreenView(viewModel: multi, purchase: purchase) + } + } + + // 15. The cancellation survey sheet. + let surveyConfiguration = defaultConfiguration() + surveyConfiguration.managementScreen.paths = surveyConfiguration.managementScreen.paths.map { path in + if path.type == .manageSubscription { + path.survey = cancellationSurvey() + } + return path + } + let surveyModel = await makeViewModel( + subscriptions: [subscription()], + configuration: surveyConfiguration + ) + if let purchase = surveyModel.purchases.first, + let manage = surveyModel.paths(for: purchase).first(where: { $0.path.type == .manageSubscription }) { + await surveyModel.select(manage, purchase: purchase) + captureDetail("15-cancellation-survey", directory: directory, viewModel: surveyModel) { + FeedbackSurveyView(viewModel: surveyModel) + } + } + + // 16. The "update your app" banner. + let updateConfiguration = defaultConfiguration() + updateConfiguration.support.shouldWarnToUpdate = true + updateConfiguration.support.latestAppVersion = "2.0.0" + capture( + "16-update-banner", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription()], + configuration: updateConfiguration, + environment: EnvironmentMock(appVersion: "1.0.0") + ) + ) + + // 17. The duplicate-subscription warning: subscribed on the App Store and on the web. + let duplicateConfiguration = defaultConfiguration() + duplicateConfiguration.warnsAboutDuplicateSubscriptions = true + capture( + "17-duplicate-subscription-warning", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [ + subscription(), + subscription( + productId: "annual_pro", + transactionId: "t3", + purchaseDate: -5, + groupId: nil, + store: .stripe + ) + ], + configuration: duplicateConfiguration + ) + ) + + // 18. No support email configured — contact support disappears. + let noSupport = CustomerCenterConfiguration.default + capture( + "18-no-support-email", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription()], + configuration: noSupport + ) + ) + + // 19. Account details switched off — the most stripped-back screen. + let minimal = defaultConfiguration() + minimal.showsAccountDetails = false + capture( + "19-minimal-configuration", + directory: directory, + viewModel: await makeViewModel(subscriptions: [subscription()], configuration: minimal) + ) + + // 20. A branded accent colour, to check the theming hook. + let branded = defaultConfiguration() + branded.appearance = .init( + accent: .init(light: UIColor.systemPurple, dark: UIColor.systemTeal) + ) + capture( + "20-custom-accent", + directory: directory, + viewModel: await makeViewModel(subscriptions: [subscription()], configuration: branded) + ) + + // 21. Restore in progress — the blocking overlay. + let restoring = await makeViewModel() + restoring.restoreState = .restoring + capture("21-restore-in-progress", directory: directory, viewModel: restoring) + + // 22. Restore finished with nothing to restore. + let restoreEmpty = await makeViewModel() + restoreEmpty.restoreState = .notFound + capture("22-restore-nothing-found", directory: directory, viewModel: restoreEmpty) + + // MARK: - Mixed stores and multiple groups + + let webManagementURL = URL(string: "https://acme.com/account") + let webSubscription = subscription( + productId: "web_pro_monthly", + transactionId: "w1", + purchaseDate: -20, + expiresIn: 10, + groupId: nil, + store: .stripe + ) + + // 23. A web subscription on its own. No Change plan or Refund — both are App Store only — + // and the manage row points at the web management page instead. + // + // `EnvironmentMock` takes the URL directly; only `LiveEnvironment` reads it out of the + // configuration. Set both, or this renders scenario 24's unconfigured screen instead. + let webConfigured = defaultConfiguration() + webConfigured.support.webManagementURL = webManagementURL + capture( + "23-web-subscription-only", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [webSubscription], + configuration: webConfigured, + environment: EnvironmentMock(webManagementURL: webManagementURL) + ) + ) + + // 24. The same customer with no management URL configured, which is what most apps will + // have on day one. The list itself is identical to 23 — the row stays put, because + // removing it would leave a paying customer with no way to manage anything — so the + // screenshot has to be what the tap produces, which is where the two diverge. + let noWebURL = defaultConfiguration() + noWebURL.support.webManagementURL = nil + let unconfigured = await makeViewModel( + subscriptions: [webSubscription], + configuration: noWebURL, + environment: EnvironmentMock(webManagementURL: nil) + ) + captureSheet( + "24-web-subscription-no-management-url", + directory: directory, + viewModel: unconfigured + ) { + Text(unconfigured.strings.string("customer_center_web_manage_unavailable")).padding() + } + + // 25. App Store and web active at once — the case where the two stores sit side by side and + // the duplicate warning fires. + let mixedStores = defaultConfiguration() + mixedStores.warnsAboutDuplicateSubscriptions = true + mixedStores.support.webManagementURL = webManagementURL + let mixed = await makeViewModel( + subscriptions: [subscription(), webSubscription], + configuration: mixedStores, + environment: EnvironmentMock(webManagementURL: webManagementURL) + ) + capture("25-app-store-and-web", directory: directory, viewModel: mixed) + + // 26 & 27. The point of the mixed case: the same screen offers different actions per + // purchase, because what a store permits differs. Apple's sub can change plan and + // request a refund; the web one can only be managed on the web. + if let appStorePurchase = mixed.purchases.first(where: { $0.store == .appStore }) { + captureDetail("26-mixed-detail-app-store", directory: directory, viewModel: mixed) { + PurchaseDetailScreenView(viewModel: mixed, purchase: appStorePurchase) + } + } + if let webPurchase = mixed.purchases.first(where: { $0.store == .stripe }) { + captureDetail("27-mixed-detail-web", directory: directory, viewModel: mixed) { + PurchaseDetailScreenView(viewModel: mixed, purchase: webPurchase) + } + } + + // 28. Two App Store subscriptions in different subscription groups, drilled into one of + // them. Change plan is scoped to that subscription's own group — Apple's sheet takes a + // single group, so there is no combined plan picker to offer. + let twoGroups = await makeViewModel( + subscriptions: [ + subscription(), + subscription( + productId: "coach_monthly", + transactionId: "t2", + purchaseDate: -10, + groupId: "group_coach" + ) + ] + ) + if let first = twoGroups.purchases.first { + captureDetail("28-two-groups-detail", directory: directory, viewModel: twoGroups) { + PurchaseDetailScreenView(viewModel: twoGroups, purchase: first) + } + } + + // 29. An entitlement with no transaction behind it — comped, or granted by hand. There is + // nothing to manage, so the management row is absent rather than pointing nowhere. + capture( + "29-comped-entitlement", + directory: directory, + viewModel: await makeViewModel(entitlements: [Entitlement(id: "pro", store: nil)]) + ) + + // 30. Shared through Family Sharing. Cancel, refund and change plan all disappear: the + // purchase belongs to the organiser, not this customer. + capture( + "30-family-shared", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription()], + familyShared: ["monthly_pro"] + ) + ) + + let written = (try? FileManager.default.contentsOfDirectory(atPath: directory.path))? + .filter { $0.hasSuffix(".png") } + .count ?? 0 + Issue.record(Comment(rawValue: "WROTE \(written) PNGs to \(directory.path)")) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift new file mode 100644 index 0000000000..cde6c86112 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift @@ -0,0 +1,74 @@ +// +// PathTitleTests.swift +// +// +// Created by Jordan Morgan on 10/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Path row titles") +struct PathTitleTests { + @available(iOS 15.0, *) + private func title( + _ type: CustomerCenterConfiguration.PathType, + pathTitle: String? = nil, + destination: ResolvedPathDestination = .restore + ) -> String { + let path = CustomerCenterConfiguration.Path(id: "p", type: type, title: pathTitle) + return PathsListView.title( + for: ResolvedPath(path: path, destination: destination), + strings: .english + ) + } + + /// The bug this rule exists to prevent. The title used to be derived from the URL's host, which + /// is identical across an app's own links — so three distinct destinations rendered as three + /// identical rows and the customer had no way to tell them apart. + @available(iOS 15.0, *) + @Test("URL rows on one host still read differently") + func urlRowsOnTheSameHostAreDistinct() { + let rows = [ + title(.url(URL(string: "https://acme.com/faq")!, title: "FAQ", openMethod: .inApp)), + title(.url(URL(string: "https://acme.com/terms")!, title: "Terms of Service", openMethod: .inApp)), + title(.url(URL(string: "https://acme.com/privacy")!, title: "Privacy Policy", openMethod: .external)) + ] + + #expect(rows == ["FAQ", "Terms of Service", "Privacy Policy"]) + #expect(Set(rows).count == 3, "a shared host must not collapse three rows into one label") + } + + /// Every other type names itself, so a title is optional there and overrides the default. + @available(iOS 15.0, *) + @Test("built-in types fall back to their localized label") + func builtInTypesNameThemselves() { + #expect(title(.restore) == CustomerCenterStrings.english.string("customer_center_path_restore")) + #expect(title(.refund()) == CustomerCenterStrings.english.string("customer_center_path_refund")) + #expect(title(.contactSupport) == CustomerCenterStrings.english.string("customer_center_path_contact_support")) + } + + @available(iOS 15.0, *) + @Test("an explicit title wins over every default", arguments: [ + CustomerCenterConfiguration.PathType.restore, + .contactSupport, + .url(URL(string: "https://acme.com/faq")!, title: "FAQ", openMethod: .inApp) + ]) + func explicitTitleWins(type: CustomerCenterConfiguration.PathType) { + #expect(title(type, pathTitle: "Help me") == "Help me") + } + + /// The manage row is the one built-in whose label depends on where it leads: a web management + /// page does more than cancel, so calling it "Cancel subscription" there undersells it. + @available(iOS 15.0, *) + @Test("the manage row is named for the store it leads to") + func manageRowNamedForItsDestination() { + let appStore = title(.manageSubscription, destination: .appleManageSheet(subscriptionGroupId: nil)) + let web = title(.manageSubscription, destination: .webManageUnavailable) + + #expect(appStore == CustomerCenterStrings.english.string("customer_center_path_manage_subscription")) + #expect(web == CustomerCenterStrings.english.string("customer_center_path_manage_subscription_web")) + #expect(appStore != web) + } +} diff --git a/Tests/SuperwallKitTests/Storage/StorageMock.swift b/Tests/SuperwallKitTests/Storage/StorageMock.swift index 40b0d9c260..148fbed172 100644 --- a/Tests/SuperwallKitTests/Storage/StorageMock.swift +++ b/Tests/SuperwallKitTests/Storage/StorageMock.swift @@ -45,7 +45,7 @@ final class StorageMock: Storage { internalCachedConfig: Config? = nil, coreDataManager: CoreDataManagerFakeDataMock = CoreDataManagerFakeDataMock(), confirmedAssignments: Set? = [], - cache: Cache = Cache() + cache: Cache = .makeDefault() ) { self.internalCachedTransactions = internalCachedTransactions self.internalConfirmedAssignments = confirmedAssignments diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index d220b8858f..c19a7bf962 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -886,8 +886,7 @@ struct WebEntitlementRedeemerTests { // Set up mock storage let mockStorage = StorageMock( - internalRedeemResponse: previousRedeemResponse, - cache: Cache() + internalRedeemResponse: previousRedeemResponse ) mockStorage.save(deviceCustomerInfo, forType: LatestDeviceCustomerInfo.self) @@ -1015,8 +1014,7 @@ struct WebEntitlementRedeemerTests { // Set up mock storage let mockStorage = StorageMock( - internalRedeemResponse: previousRedeemResponse, - cache: Cache() + internalRedeemResponse: previousRedeemResponse ) mockStorage.save(deviceCustomerInfo, forType: LatestDeviceCustomerInfo.self) @@ -2136,7 +2134,10 @@ struct WebEntitlementRedeemerTests { receiptManager: dependencyContainer.receiptManager, factory: dependencyContainer, stripePendingPollIntervalNs: 1_000_000, - stripePendingPollTimeoutNs: 5_000_000, + // Wide margins keep both legs deterministic under parallel test execution: the fresh + // state stays comfortably inside the timeout even if the check runs long after the + // register, and the expired state is far older than the timeout. + stripePendingPollTimeoutNs: 60_000_000_000, superwall: superwall ) @@ -2147,7 +2148,7 @@ struct WebEntitlementRedeemerTests { PendingStripeCheckoutPollState( checkoutContextId: "ctx_expired", productId: "prod_expired", - updatedAt: Date(timeIntervalSinceNow: -10) + updatedAt: Date(timeIntervalSinceNow: -120) ), forType: PendingStripeCheckoutPollStorage.self ) diff --git a/project.yml b/project.yml index 5d4094703d..29cc6a0573 100644 --- a/project.yml +++ b/project.yml @@ -12,8 +12,17 @@ targets: scheme: testTargets: - name: SuperwallKitTests + parallelizable: true commandLineArguments: SUPERWALL_UNIT_TESTS: true + environmentVariables: + # Where `DesignReviewSnapshots` writes its PNGs. Off by default — the suite is dormant + # without it. Tick it in Xcode's scheme editor to render the design-review screens. It + # has to live on the scheme: a plain `CUSTOMER_CENTER_SNAPSHOT_DIR=... xcodebuild test` + # never reaches the test process running in the simulator. + - variable: CUSTOMER_CENTER_SNAPSHOT_DIR + value: /tmp/customer-center-screens + isEnabled: false settings: PRODUCT_BUNDLE_IDENTIFIER: "com.superwall.SuperwallKit" info: