From 4a76c2c693e3456f87032cf548bc9bd854270285 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 13:17:02 -0500 Subject: [PATCH 01/42] feat(customer-center): add CustomerCenterConfiguration model and SuperwallOptions.customerCenter --- .../Config/Options/SuperwallOptions.swift | 3 + .../Models/CustomerCenterConfiguration.swift | 311 ++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 46 ++- .../xcschemes/SuperwallKit.xcscheme | 3 +- .../CustomerCenterConfigurationTests.swift | 53 +++ 5 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift 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/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift new file mode 100644 index 0000000000..c5ba9305f5 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -0,0 +1,311 @@ +// +// 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 noActiveScreen: 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 a "See all purchases" link to the purchase history screen. Defaults to `true`. + public var showsPurchaseHistory: Bool + /// 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, + noActiveScreen: Screen, + support: Support = Support(), + appearance: Appearance = Appearance(), + showsPurchaseHistory: Bool = true, + showsAccountDetails: Bool = true, + warnsAboutDuplicateSubscriptions: Bool = true + ) { + self.managementScreen = managementScreen + self.noActiveScreen = noActiveScreen + self.support = support + self.appearance = appearance + self.showsPurchaseHistory = showsPurchaseHistory + 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-active 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) + ] + ), + noActiveScreen: 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 + && noActiveScreen == other.noActiveScreen + && support == other.support + && appearance == other.appearance + && showsPurchaseHistory == other.showsPurchaseHistory + && showsAccountDetails == other.showsAccountDetails + && warnsAboutDuplicateSubscriptions == other.warnsAboutDuplicateSubscriptions + } + + // 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-active 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 + } + } + + // 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 + } + } + + /// The kinds of path the Customer Center supports. + public enum PathType: Codable, Equatable { + 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 + case url(URL, openMethod: OpenMethod) + case custom(identifier: String) + } + + /// How a URL path opens. + public enum OpenMethod: String, Codable { + 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 + } + + @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 + } + } + } + + // 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 + /// 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, + webManagementURL: URL? = nil + ) { + self.email = email + self.latestAppVersion = latestAppVersion + self.shouldWarnToUpdate = shouldWarnToUpdate + self.webManagementURL = webManagementURL + } + + 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 + && webManagementURL == other.webManagementURL + } + } + + // MARK: - Appearance + + @objc(SWKCustomerCenterAppearance) + @objcMembers + public final class Appearance: NSObject, Codable { + public var accent: ColorPair? + public var background: ColorPair? + public var text: ColorPair? + public var buttonText: ColorPair? + public var buttonBackground: ColorPair? + + public init( + accent: ColorPair? = nil, + background: ColorPair? = nil, + text: ColorPair? = nil, + buttonText: ColorPair? = nil, + buttonBackground: ColorPair? = nil + ) { + self.accent = accent + self.background = background + self.text = text + self.buttonText = buttonText + self.buttonBackground = buttonBackground + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Appearance else { return false } + return accent == other.accent && background == other.background && text == other.text + && buttonText == other.buttonText && buttonBackground == other.buttonBackground + } + + /// 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 + } + } + } +} + +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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 817f1bf3cc..a75823d4cc 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -192,6 +192,7 @@ 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 */; }; @@ -463,6 +464,7 @@ 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 */; }; + 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 */; }; @@ -708,6 +710,7 @@ 2D3DB70C19B7C07E1750DB8F /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; 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 = ""; }; @@ -975,6 +978,7 @@ 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 = ""; }; 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 = ""; }; @@ -1760,6 +1764,14 @@ path = "Web View"; sourceTree = ""; }; + 3EFE894723C00D72A3C01061 /* CustomerCenter */ = { + isa = PBXGroup; + children = ( + E40538D195AAE4E177C98959 /* Models */, + ); + path = CustomerCenter; + sourceTree = ""; + }; 41C20E3AA2F12F64126C7D72 /* StoreTransaction */ = { isa = PBXGroup; children = ( @@ -2275,6 +2287,7 @@ 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */, 33C89C06ECF942287FA14087 /* Analytics */, 91B8F43244A7C30402275032 /* Config */, + E4455CBE23BD58AF980439B4 /* CustomerCenter */, 97F6AA52B81B82F72AB80D7C /* Debug */, 9C21AAF80FD220C960FE568F /* Delegate */, A34416D82C5BDBAA82A119C1 /* Dependencies */, @@ -2573,6 +2586,14 @@ path = Logic; sourceTree = ""; }; + AC076DCADFAF818A0325BA18 /* Models */ = { + isa = PBXGroup; + children = ( + 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, + ); + path = Models; + sourceTree = ""; + }; AC10C98F9F6D03AD79376ADE /* Capabilities */ = { isa = PBXGroup; children = ( @@ -2714,6 +2735,7 @@ FE0E783785F917188D12F4B4 /* Utils.swift */, E338A5AE4BB82E592AE9B9BA /* Analytics */, D554340BB6652F5FA1F21FF8 /* Config */, + 3EFE894723C00D72A3C01061 /* CustomerCenter */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, 373AFF230833A951B6E5DF36 /* Identity */, @@ -2822,8 +2844,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 +2978,22 @@ path = "Receipt Manager"; sourceTree = ""; }; + E40538D195AAE4E177C98959 /* Models */ = { + isa = PBXGroup; + children = ( + A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */, + ); + path = Models; + sourceTree = ""; + }; + E4455CBE23BD58AF980439B4 /* CustomerCenter */ = { + isa = PBXGroup; + children = ( + AC076DCADFAF818A0325BA18 /* Models */, + ); + path = CustomerCenter; + sourceTree = ""; + }; E4C1D2384C6CAD193D3CE652 /* Templating */ = { isa = PBXGroup; children = ( @@ -3173,9 +3211,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 +3267,7 @@ 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); preferredProjectObjectVersion = 77; + productRefGroup = 778C04FFAA9840C37CA3C1CA /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -3285,6 +3325,7 @@ B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */, 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */, 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, + D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, @@ -3465,6 +3506,7 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */, D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */, 9E21D97817B1BA97806283B3 /* CustomURLSession.swift in Sources */, + 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, C5EA22647EFADC126DC4BFE8 /* Date+IsoString.swift in Sources */, diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index e2f319bd61..8c5e0a1832 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -40,7 +40,8 @@ + skipped = "NO" + parallelizable = "NO"> Date: Thu, 20 Aug 2026 13:31:23 -0500 Subject: [PATCH 02/42] feat(customer-center): add CustomerCenterAction, refund status, ObjC path factories, LogScope.customerCenter --- .../Models/CustomerCenterAction.swift | 92 +++++++++++++++++++ .../CustomerCenterConfiguration+ObjC.swift | 76 +++++++++++++++ Sources/SuperwallKit/Logger/LogScope.swift | 3 + SuperwallKit.xcodeproj/project.pbxproj | 12 +++ .../Models/CustomerCenterActionTests.swift | 41 +++++++++ 5 files changed, 224 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift new file mode 100644 index 0000000000..9381349a5c --- /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+ObjC.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift new file mode 100644 index 0000000000..b2de070bc9 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift @@ -0,0 +1,76 @@ +// +// 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) + } + @objc public static func url(id: String, url: URL, openMethod: CustomerCenterOpenMethodObjc, title: String?) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .url(url, 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/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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index a75823d4cc..e7baf03bcc 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -163,6 +163,7 @@ 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */; }; 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6BF63B250AE0D83DECFCD0 /* EmailTests.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 */; }; @@ -375,6 +376,7 @@ 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 */; }; + 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 */; }; @@ -402,6 +404,7 @@ B91D4755E1FDCBBC2D3CD8C3 /* InternalPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36299FDDEC7022F0F45A801 /* InternalPresentation.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 */; }; BC526F821C0BDAC76D7B3769 /* LocationAuthorizationStatusConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD9CFF209DA6B8B42B405D20 /* LocationAuthorizationStatusConversionTests.swift */; }; @@ -708,6 +711,7 @@ 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 = ""; }; @@ -845,6 +849,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 = ""; }; @@ -951,6 +956,7 @@ 9C2580C3CD6A8BF0C5258665 /* SWWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebView.swift; sourceTree = ""; }; 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK1ReceiptManager.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 = ""; }; @@ -2589,7 +2595,9 @@ AC076DCADFAF818A0325BA18 /* Models */ = { isa = PBXGroup; children = ( + 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */, 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, + 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */, ); path = Models; sourceTree = ""; @@ -2981,6 +2989,7 @@ E40538D195AAE4E177C98959 /* Models */ = { isa = PBXGroup; children = ( + 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */, A6BCA6546821A143D0087CD9 /* CustomerCenterConfigurationTests.swift */, ); path = Models; @@ -3325,6 +3334,7 @@ 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 */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, @@ -3506,6 +3516,8 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */, D90B2915CA23976F48794449 /* CustomStoreTransaction.swift in Sources */, 9E21D97817B1BA97806283B3 /* CustomURLSession.swift in Sources */, + B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */, + BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift new file mode 100644 index 0000000000..273f8ebc14 --- /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, 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) + } +} From 7d5786524f7a56fec6ab81bab7ba905f491859d4 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 13:58:05 -0500 Subject: [PATCH 03/42] feat(customer-center): add Customer Center analytics events Adds SuperwallEvent.customerCenterOpen/Close/Action/SurveyResponse/RefundRequest with ObjC mirrors and InternalSuperwallEvent trackable structs. --- .../TrackableSuperwallEvent.swift | 63 +++++++++++++++++++ .../Superwall Placement/SuperwallEvent.swift | 32 ++++++++++ .../SuperwallEventObjc.swift | 23 ++++++- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../CustomerCenterEventsTests.swift | 37 +++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index eb366a8352..d7210ec91a 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -1210,6 +1210,69 @@ enum InternalSuperwallEvent { } } + struct CustomerCenterOpen: TrackableSuperwallEvent { + let screen: String + var superwallEvent: SuperwallEvent { .customerCenterOpen(screen: screen) } + var audienceFilterParams: [String: Any] = [:] + func getSuperwallParameters() async -> [String: Any] { ["screen": screen] } + } + + 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..4be16602fb 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_active`. + 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e7baf03bcc..27d97e3762 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -198,6 +198,7 @@ 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 */; }; 5C504112376B6E0798CA20CE /* Variables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B75209DF76859131941CA0F /* Variables.swift */; }; 5D0DAFA97F75920FFB99DF6B /* PriceFormatterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */; }; @@ -891,6 +892,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 = ""; }; @@ -1773,6 +1775,7 @@ 3EFE894723C00D72A3C01061 /* CustomerCenter */ = { isa = PBXGroup; children = ( + 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, E40538D195AAE4E177C98959 /* Models */, ); path = CustomerCenter; @@ -3336,6 +3339,7 @@ 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */, D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, + 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift new file mode 100644 index 0000000000..98839d0180 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift @@ -0,0 +1,37 @@ +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("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") + } +} From 77beeddfdbbfb04eed59b15a18089b9bdaf3fb04 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 14:14:54 -0500 Subject: [PATCH 04/42] feat(customer-center): add AppVersionComparator Co-Authored-By: Claude Fable 5 --- .../Logic/AppVersionComparator.swift | 26 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 24 +++++++++++++++++ .../Logic/AppVersionComparatorTests.swift | 15 +++++++++++ 3 files changed, 65 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift new file mode 100644 index 0000000000..6fa97e1a9d --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift @@ -0,0 +1,26 @@ +// Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift +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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 27d97e3762..69c9cbb952 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -200,6 +200,7 @@ 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 */; }; 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */; }; @@ -435,6 +436,7 @@ 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 */; }; + 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 */; }; @@ -604,6 +606,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 = ""; }; @@ -639,6 +642,7 @@ 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 = ""; }; 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestManager.swift; sourceTree = ""; }; 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; @@ -1776,6 +1780,7 @@ isa = PBXGroup; children = ( 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, + 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, ); path = CustomerCenter; @@ -1874,6 +1879,14 @@ path = Location; sourceTree = ""; }; + 4664D61C9B4C8ADC2B834E36 /* Logic */ = { + isa = PBXGroup; + children = ( + 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + ); + path = Logic; + sourceTree = ""; + }; 46CEF77B52399F8A27F8452F /* Events */ = { isa = PBXGroup; children = ( @@ -2027,6 +2040,14 @@ path = Operators; sourceTree = ""; }; + 5E4DEFC8C051825F0007162E /* Logic */ = { + isa = PBXGroup; + children = ( + 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, + ); + path = Logic; + sourceTree = ""; + }; 61A371A2B16AE3626D64EA20 /* Presentation State */ = { isa = PBXGroup; children = ( @@ -3001,6 +3022,7 @@ E4455CBE23BD58AF980439B4 /* CustomerCenter */ = { isa = PBXGroup; children = ( + 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, ); path = CustomerCenter; @@ -3314,6 +3336,7 @@ 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */, A9FC64A249BF2242BB526521 /* AppStoreProductTests.swift in Sources */, + 5B254755EE51075D28EA9282 /* AppVersionComparatorTests.swift in Sources */, 59685CE55D34FA6A96A8F890 /* AssignmentLogicTests.swift in Sources */, BC8A62869C7BACE6D0867195 /* AssignmentTests.swift in Sources */, 3CD2C23BAC2EA11174237785 /* AttributionTests.swift in Sources */, @@ -3471,6 +3494,7 @@ 995FD66283C7B03D3B33DF89 /* AppSessionLogic.swift in Sources */, E986B0CF98B8C09AAA961E94 /* AppSessionManager.swift in Sources */, 5DE5CE789559545FF1A8AD12 /* AppStoreProduct.swift in Sources */, + C71FC781059E1BE197CE9C38 /* AppVersionComparator.swift in Sources */, 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */, F61541FC6670E0667A96FE44 /* ArchiveManifest.swift in Sources */, FCF3B638D0D802202113DCBD /* ArchiveManifestUsage.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift new file mode 100644 index 0000000000..d2cd0d69cf --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift @@ -0,0 +1,15 @@ +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) + } +} From 2c9a9737fd6348970bb02f7472b3f1668b85175a Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 14:19:57 -0500 Subject: [PATCH 05/42] feat(customer-center): add SupportEmailComposer Co-Authored-By: Claude Fable 5 --- .../Logic/SupportEmailComposer.swift | 57 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 8 +++ .../Logic/SupportEmailComposerTests.swift | 45 +++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift new file mode 100644 index 0000000000..ebd2d4dc5f --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift @@ -0,0 +1,57 @@ +// +// SupportEmailComposer.swift +// +// +// Created by Claude 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 69c9cbb952..5f7eed298b 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -313,6 +313,7 @@ 91BA5E01D0FB528954ABB937 /* StripeStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AD42859B8BFEC078665FA1E /* StripeStoreProductDiscount.swift */; }; 9304297F3B76DB512F2F9D53 /* TrackingLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2300AFFC31667E749E85EAC /* TrackingLogicTests.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 */; }; @@ -371,6 +372,7 @@ 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 */; }; @@ -774,6 +776,7 @@ 4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivalManifestDownloaded.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 = ""; }; @@ -800,6 +803,7 @@ 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManagerTests.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 = ""; }; 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerMock.swift; sourceTree = ""; }; 5CD130C74880AD07DCD2A7AA /* RedemptionResultObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedemptionResultObjc.swift; sourceTree = ""; }; @@ -1883,6 +1887,7 @@ isa = PBXGroup; children = ( 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, ); path = Logic; sourceTree = ""; @@ -2044,6 +2049,7 @@ isa = PBXGroup; children = ( 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, + 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, ); path = Logic; sourceTree = ""; @@ -3450,6 +3456,7 @@ 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 */, @@ -3829,6 +3836,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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift new file mode 100644 index 0000000000..911a359d41 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift @@ -0,0 +1,45 @@ +// +// SupportEmailComposerTests.swift +// +// +// Created by Claude 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")) + } +} From 8bb052b615a3310a3628898814f0ac5b903b8c1a Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 14:29:07 -0500 Subject: [PATCH 06/42] feat(customer-center): add PurchasePresentation model and builder Co-Authored-By: Claude Fable 5 --- .../Logic/PurchasePresentationBuilder.swift | 166 ++++++++++++++++++ .../Models/PurchasePresentation.swift | 49 ++++++ .../Views/CustomerCenterStrings+English.swift | 41 +++++ SuperwallKit.xcodeproj/project.pbxproj | 24 +++ .../PurchasePresentationBuilderTests.swift | 145 +++++++++++++++ 5 files changed, 425 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift new file mode 100644 index 0000000000..0c666681e1 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -0,0 +1,166 @@ +// +// 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 = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + + func build(customerInfo: CustomerInfo, products: [String: ProductDisplayInfo]) -> [PurchasePresentation] { + let subs = subscriptionPresentations(customerInfo.subscriptions, products: products) + let nonSubs = nonSubscriptionPresentations(customerInfo.nonSubscriptions, products: products) + 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 + } + + func subscriptionPresentations( + _ subscriptions: [SubscriptionTransaction], + products: [String: ProductDisplayInfo] + ) -> [PurchasePresentation] { + let sorted = subscriptions.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]) } + } + + func nonSubscriptionPresentations( + _ purchases: [NonSubscriptionTransaction], + products: [String: ProductDisplayInfo] + ) -> [PurchasePresentation] { + purchases.sorted { $0.purchaseDate < $1.purchaseDate }.map { purchase in + let product = products[purchase.productId] + return PurchasePresentation( + id: purchase.productId, + kind: .nonSubscription(purchase), + productId: purchase.productId, + title: product?.title ?? 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?) -> 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_revoked") + 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 ?? sub.productId, + 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/Models/PurchasePresentation.swift b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift new file mode 100644 index 0000000000..68522b305d --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift @@ -0,0 +1,49 @@ +// +// 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 + 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? + 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 + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift new file mode 100644 index 0000000000..97b532d974 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -0,0 +1,41 @@ +// +// 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 } +} + +/// English literals keyed by localization key. Extended in Task 9 with the remaining +/// Customer Center strings; this task only adds the keys `PurchasePresentationBuilder` uses. +let englishStrings: [String: String] = [ + "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_store_web": "Web", + "customer_center_store_google_play": "Google Play", + "customer_center_store_superwall": "Superwall", + "customer_center_store_other": "Other" +] diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 5f7eed298b..d1bbb251a3 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -138,6 +138,7 @@ 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 */; }; @@ -186,6 +187,7 @@ 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 */; }; 558A89440F2E1B052316FE57 /* LogPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115F0BE94943D7B60CDDD4A /* LogPresentation.swift */; }; @@ -435,6 +437,7 @@ C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */; }; C3897720526685D55A27C56C /* AttributionPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B31ACE25727649F21DEEBAF /* AttributionPoster.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 */; }; @@ -472,6 +475,7 @@ 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 */; }; @@ -750,6 +754,7 @@ 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 = ""; }; @@ -780,6 +785,7 @@ 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 = ""; }; 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 = ""; }; @@ -956,6 +962,7 @@ 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 = ""; }; 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 = ""; }; @@ -964,6 +971,7 @@ 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 = ""; }; 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = ""; }; 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterActionTests.swift; sourceTree = ""; }; @@ -1362,6 +1370,14 @@ path = Purchasing; sourceTree = ""; }; + 1422D4F63A53E2768C2E90E6 /* Views */ = { + isa = PBXGroup; + children = ( + 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */, + ); + path = Views; + sourceTree = ""; + }; 14F8842196EB2B1F82B3E024 /* Network */ = { isa = PBXGroup; children = ( @@ -1887,6 +1903,7 @@ isa = PBXGroup; children = ( 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, ); path = Logic; @@ -2049,6 +2066,7 @@ isa = PBXGroup; children = ( 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, + 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, ); path = Logic; @@ -2628,6 +2646,7 @@ 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */, 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */, + 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */, ); path = Models; sourceTree = ""; @@ -3030,6 +3049,7 @@ children = ( 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, + 1422D4F63A53E2768C2E90E6 /* Views */, ); path = CustomerCenter; sourceTree = ""; @@ -3437,6 +3457,7 @@ 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, + D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */, @@ -3554,6 +3575,7 @@ B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */, BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, + 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, C5EA22647EFADC126DC4BFE8 /* Date+IsoString.swift in Sources */, @@ -3757,6 +3779,8 @@ 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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift new file mode 100644 index 0000000000..2ce75e0c15 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -0,0 +1,145 @@ +// +// 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("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 falls back to product id and omits price") + func missingProduct() { + let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: [:]) + #expect(rows[0].title == "monthly") + #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: [:]) + #expect(rows.map(\.id) == ["soon", "late", "dead", "coins", "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) + } +} From 8065120d5c54bccb2af70d728c2d065ddd32925d Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 14:39:09 -0500 Subject: [PATCH 07/42] feat(customer-center): add CustomerCenterPathResolver Co-Authored-By: Claude Fable 5 --- .../Logic/CustomerCenterPathResolver.swift | 104 +++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../CustomerCenterPathResolverTests.swift | 105 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift new file mode 100644 index 0000000000..1896d1403f --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -0,0 +1,104 @@ +// +// 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 + var now = Date() +} + +enum ResolvedPathDestination: Equatable { + case restore + case appleManageSheet(subscriptionGroupId: String?) + case webManage(URL) + 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 +} + +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) } + } + } + + 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: + return purchase == nil ? .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: + guard let purchase else { return nil } + if isAppStore { + 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) + } + if isWebStore, let url = context.webManagementURL { + return .webManage(url) + } + return nil + + 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d1bbb251a3..06d9b3d6ae 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 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 */; }; @@ -559,6 +560,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 */; }; @@ -761,6 +763,7 @@ 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 = ""; }; @@ -1205,6 +1208,7 @@ F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+mergeChanges.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 = ""; }; @@ -1903,6 +1907,7 @@ isa = PBXGroup; children = ( 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, ); @@ -2066,6 +2071,7 @@ isa = PBXGroup; children = ( 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, + F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */, 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, ); @@ -3389,6 +3395,7 @@ 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */, D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, + F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, @@ -3575,6 +3582,7 @@ B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */, BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, + 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift new file mode 100644 index 0000000000..6770be08db --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift @@ -0,0 +1,105 @@ +// +// 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) -> PathResolutionContext { + PathResolutionContext(purchase: purchase, product: product, isFamilyShared: family, supportEmailAvailable: email, + webManagementURL: web, isChangePlanSheetAvailable: changePlan, canOpenURLs: canOpen, 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")!, 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("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: only webManage (when URL) + contactSupport; 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]) + #expect(destinations(context(presentation(sub(store: .stripe), product: nil))) == [.contactSupport]) + #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")!, openMethod: .external))) + #expect(destinations(context(nil, canOpen: false), p) == [.restore]) + } +} From c89bd2ef4c7cb858cb2fde2f706433f6ef593bd6 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 14:44:10 -0500 Subject: [PATCH 08/42] feat(customer-center): add StoreKit transaction lookup Co-Authored-By: Claude Fable 5 --- .../Actions/StoreKitTransactionLookup.swift | 31 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 24 ++++++++++++++ .../StoreKitTransactionLookupMock.swift | 22 +++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Actions/StoreKitTransactionLookup.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Actions/StoreKitTransactionLookupMock.swift 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 06d9b3d6ae..60412584bf 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -170,6 +170,7 @@ 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 */; }; @@ -229,6 +230,7 @@ 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 */; }; @@ -1057,6 +1059,7 @@ 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 = ""; }; 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 = ""; }; @@ -1161,6 +1164,7 @@ 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 = ""; }; @@ -1804,6 +1808,7 @@ isa = PBXGroup; children = ( 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, + 9723663065538DB5CF16F4A4 /* Actions */, 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, ); @@ -1944,6 +1949,14 @@ path = "Push Transition"; sourceTree = ""; }; + 4AC7FD1A50349966FF78DB51 /* Actions */ = { + isa = PBXGroup; + children = ( + B8F5F084F94D853AA5B5CC79 /* StoreKitTransactionLookup.swift */, + ); + path = Actions; + sourceTree = ""; + }; 4D7656D6A565958F58A644AF /* Misc */ = { isa = PBXGroup; children = ( @@ -2513,6 +2526,14 @@ path = Contacts; sourceTree = ""; }; + 9723663065538DB5CF16F4A4 /* Actions */ = { + isa = PBXGroup; + children = ( + DF4B8468A75C8948EEFC6D4E /* StoreKitTransactionLookupMock.swift */, + ); + path = Actions; + sourceTree = ""; + }; 97F6AA52B81B82F72AB80D7C /* Debug */ = { isa = PBXGroup; children = ( @@ -3053,6 +3074,7 @@ E4455CBE23BD58AF980439B4 /* CustomerCenter */ = { isa = PBXGroup; children = ( + 4AC7FD1A50349966FF78DB51 /* Actions */, 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, 1422D4F63A53E2768C2E90E6 /* Views */, @@ -3479,6 +3501,7 @@ 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 */, @@ -3837,6 +3860,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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Actions/StoreKitTransactionLookupMock.swift b/Tests/SuperwallKitTests/CustomerCenter/Actions/StoreKitTransactionLookupMock.swift new file mode 100644 index 0000000000..be14c80dfb --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Actions/StoreKitTransactionLookupMock.swift @@ -0,0 +1,22 @@ +// +// StoreKitTransactionLookupMock.swift +// SuperwallKitTests +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Foundation +@testable import SuperwallKit + +final class StoreKitTransactionLookupMock: StoreKitTransactionLooking, @unchecked Sendable { + var transactionIDs: [String: UInt64] = [:] + var familyShared: Set = [] + + func latestTransactionID(for productId: String) async -> UInt64? { + transactionIDs[productId] + } + + func isFamilyShared(productId: String) async -> Bool { + familyShared.contains(productId) + } +} From 722772d79f7ac04d95a62b94b6410d87ebcfd9b3 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 15:08:11 -0500 Subject: [PATCH 09/42] feat(customer-center): add localized strings Adds the Customer Center's 74 string keys (screens, paths, survey, purchase status, badges, stores, sections, restore, refund, update warning, duplicate subscriptions, and support) to all 41 Localizable.strings bundles, plus the bundle-backed CustomerCenterStrings.bundled(locale:). Also folds in two items deferred from Task 6's review: a dedicated customer_center_expired key so an inactive subscription with no expiration date shows "Expired" instead of "Refunded", and a regression test for nil-expiration sort ordering. --- .../Logic/PurchasePresentationBuilder.swift | 2 +- .../Views/CustomerCenterStrings+English.swift | 91 ++++++++++++++++- .../ar.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ca.lproj/Localizable.strings | 98 +++++++++++++++++++ .../cs.lproj/Localizable.strings | 98 +++++++++++++++++++ .../da.lproj/Localizable.strings | 98 +++++++++++++++++++ .../de.lproj/Localizable.strings | 98 +++++++++++++++++++ .../el.lproj/Localizable.strings | 98 +++++++++++++++++++ .../en.lproj/Localizable.strings | 98 +++++++++++++++++++ .../en_AU.lproj/Localizable.strings | 98 +++++++++++++++++++ .../en_GB.lproj/Localizable.strings | 98 +++++++++++++++++++ .../es.lproj/Localizable.strings | 98 +++++++++++++++++++ .../es_419.lproj/Localizable.strings | 98 +++++++++++++++++++ .../fi.lproj/Localizable.strings | 98 +++++++++++++++++++ .../fr.lproj/Localizable.strings | 98 +++++++++++++++++++ .../fr_CA.lproj/Localizable.strings | 98 +++++++++++++++++++ .../he.lproj/Localizable.strings | 98 +++++++++++++++++++ .../hi.lproj/Localizable.strings | 98 +++++++++++++++++++ .../hr.lproj/Localizable.strings | 98 +++++++++++++++++++ .../hu.lproj/Localizable.strings | 98 +++++++++++++++++++ .../id.lproj/Localizable.strings | 98 +++++++++++++++++++ .../it.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ja.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ko.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ms.lproj/Localizable.strings | 98 +++++++++++++++++++ .../nb.lproj/Localizable.strings | 98 +++++++++++++++++++ .../nl.lproj/Localizable.strings | 98 +++++++++++++++++++ .../nn.lproj/Localizable.strings | 98 +++++++++++++++++++ .../pl.lproj/Localizable.strings | 98 +++++++++++++++++++ .../pt.lproj/Localizable.strings | 98 +++++++++++++++++++ .../pt_BR.lproj/Localizable.strings | 98 +++++++++++++++++++ .../pt_PT.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ro.lproj/Localizable.strings | 98 +++++++++++++++++++ .../ru.lproj/Localizable.strings | 98 +++++++++++++++++++ .../sk.lproj/Localizable.strings | 98 +++++++++++++++++++ .../sl.lproj/Localizable.strings | 98 +++++++++++++++++++ .../sv.lproj/Localizable.strings | 98 +++++++++++++++++++ .../th.lproj/Localizable.strings | 98 +++++++++++++++++++ .../tr.lproj/Localizable.strings | 98 +++++++++++++++++++ .../uk.lproj/Localizable.strings | 98 +++++++++++++++++++ .../vi.lproj/Localizable.strings | 98 +++++++++++++++++++ .../zh_Hans.lproj/Localizable.strings | 98 +++++++++++++++++++ .../zh_Hant.lproj/Localizable.strings | 98 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 4 + .../CustomerCenterStringsTests.swift | 52 ++++++++++ .../PurchasePresentationBuilderTests.swift | 15 +++ 46 files changed, 4178 insertions(+), 4 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/CustomerCenterStringsTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift index 0c666681e1..e09c559380 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -80,7 +80,7 @@ struct PurchasePresentationBuilder { case .revoked: status = strings.string("customer_center_revoked") case .expired: status = date.map { strings.string("customer_center_expired_on", $0) } - ?? strings.string("customer_center_revoked") + ?? 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) } ?? "" diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 97b532d974..2374af41ff 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -20,9 +20,40 @@ struct CustomerCenterStrings { static let english = CustomerCenterStrings { key in englishStrings[key] ?? key } } -/// English literals keyed by localization key. Extended in Task 9 with the remaining -/// Customer Center strings; this task only adds the keys `PurchasePresentationBuilder` uses. +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_active_title": "No subscriptions found", + "customer_center_no_active_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": "Manage subscription", + "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 %@", @@ -34,8 +65,62 @@ let englishStrings: [String: String] = [ "customer_center_purchased_on": "Purchased on %@", "customer_center_active_via_superwall": "Active", "customer_center_price_per_period": "%@ / %@", + "customer_center_expired": "Expired", + "customer_center_purchase_date": "Purchase date", + "customer_center_expiration_date": "Expiration date", + // 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_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_see_all_purchases": "See all purchases", + "customer_center_purchase_history": "Purchase history", + "customer_center_history_active": "Active subscriptions", + "customer_center_history_expired": "Expired subscriptions", + "customer_center_history_other": "Other purchases", + "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_transaction_id": "Transaction ID", + "customer_center_product_id": "Product ID", + "customer_center_store": "Store", + "customer_center_sandbox": "Sandbox", + // 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/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 06a6fb3289..0cfc73b564 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "تم"; + +/* Customer Center – screens */ +"customer_center_management_title" = "إدارة اشتراكك"; +"customer_center_no_active_title" = "لم يتم العثور على اشتراكات"; +"customer_center_no_active_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_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_purchase_date" = "تاريخ الشراء"; +"customer_center_expiration_date" = "تاريخ انتهاء الصلاحية"; + +/* 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_see_all_purchases" = "عرض جميع المشتريات"; +"customer_center_purchase_history" = "سجل المشتريات"; +"customer_center_history_active" = "الاشتراكات النشطة"; +"customer_center_history_expired" = "الاشتراكات المنتهية"; +"customer_center_history_other" = "مشتريات أخرى"; +"customer_center_account_details" = "تفاصيل الحساب"; +"customer_center_user_id" = "معرّف المستخدم"; +"customer_center_copy" = "نسخ"; +"customer_center_copied" = "تم النسخ"; +"customer_center_original_download_date" = "تاريخ التنزيل الأصلي"; +"customer_center_transaction_id" = "معرّف المعاملة"; +"customer_center_product_id" = "معرّف المنتج"; +"customer_center_store" = "المتجر"; +"customer_center_sandbox" = "بيئة اختبار (Sandbox)"; + +/* 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" = "لا يوجد تطبيق بريد مُهيأ على هذا الجهاز. يمكنك التواصل معنا عبر %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index 2f797c9929..e79d1ff73d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fet"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestiona la teva subscripció"; +"customer_center_no_active_title" = "No s'ha trobat cap subscripció"; +"customer_center_no_active_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" = "Gestiona la subscripció"; +"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_purchase_date" = "Data de compra"; +"customer_center_expiration_date" = "Data de caducitat"; + +/* 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_see_all_purchases" = "Veure totes les compres"; +"customer_center_purchase_history" = "Historial de compres"; +"customer_center_history_active" = "Subscripcions actives"; +"customer_center_history_expired" = "Subscripcions caducades"; +"customer_center_history_other" = "Altres compres"; +"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_transaction_id" = "ID de transacció"; +"customer_center_product_id" = "ID del producte"; +"customer_center_store" = "Botiga"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 615d4dfaa9..5d8e9ca912 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Hotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Spravovat vaše předplatné"; +"customer_center_no_active_title" = "Nebylo nalezeno žádné předplatné"; +"customer_center_no_active_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" = "Spravovat předplatné"; +"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_purchase_date" = "Datum nákupu"; +"customer_center_expiration_date" = "Datum vypršení platnosti"; + +/* 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_see_all_purchases" = "Zobrazit všechny nákupy"; +"customer_center_purchase_history" = "Historie nákupů"; +"customer_center_history_active" = "Aktivní předplatná"; +"customer_center_history_expired" = "Vypršelá předplatná"; +"customer_center_history_other" = "Ostatní nákupy"; +"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_transaction_id" = "ID transakce"; +"customer_center_product_id" = "ID produktu"; +"customer_center_store" = "Obchod"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index f640444c4f..543118f13d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Færdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer dit abonnement"; +"customer_center_no_active_title" = "Ingen abonnementer fundet"; +"customer_center_no_active_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" = "Administrer abonnement"; +"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_purchase_date" = "Købsdato"; +"customer_center_expiration_date" = "Udløbsdato"; + +/* 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_see_all_purchases" = "Se alle køb"; +"customer_center_purchase_history" = "Købshistorik"; +"customer_center_history_active" = "Aktive abonnementer"; +"customer_center_history_expired" = "Udløbne abonnementer"; +"customer_center_history_other" = "Andre køb"; +"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_transaction_id" = "Transaktions-id"; +"customer_center_product_id" = "Produkt-id"; +"customer_center_store" = "Butik"; +"customer_center_sandbox" = "Sandbox"; + +/* 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å %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index d9ea03f4da..cc2ef25d11 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fertig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Abo verwalten"; +"customer_center_no_active_title" = "Keine Abonnements gefunden"; +"customer_center_no_active_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 verwalten"; +"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_purchase_date" = "Kaufdatum"; +"customer_center_expiration_date" = "Ablaufdatum"; + +/* 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_see_all_purchases" = "Alle Käufe anzeigen"; +"customer_center_purchase_history" = "Kaufverlauf"; +"customer_center_history_active" = "Aktive Abos"; +"customer_center_history_expired" = "Abgelaufene Abos"; +"customer_center_history_other" = "Andere Käufe"; +"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_transaction_id" = "Transaktions-ID"; +"customer_center_product_id" = "Produkt-ID"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 851ad742ea..4e0fa83dc0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Τέλος"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Διαχείριση της συνδρομής σας"; +"customer_center_no_active_title" = "Δεν βρέθηκαν συνδρομές"; +"customer_center_no_active_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_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_purchase_date" = "Ημερομηνία αγοράς"; +"customer_center_expiration_date" = "Ημερομηνία λήξης"; + +/* 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_see_all_purchases" = "Προβολή όλων των αγορών"; +"customer_center_purchase_history" = "Ιστορικό αγορών"; +"customer_center_history_active" = "Ενεργές συνδρομές"; +"customer_center_history_expired" = "Ληγμένες συνδρομές"; +"customer_center_history_other" = "Άλλες αγορές"; +"customer_center_account_details" = "Στοιχεία λογαριασμού"; +"customer_center_user_id" = "Αναγνωριστικό χρήστη"; +"customer_center_copy" = "Αντιγραφή"; +"customer_center_copied" = "Αντιγράφηκε"; +"customer_center_original_download_date" = "Αρχική ημερομηνία λήψης"; +"customer_center_transaction_id" = "Αναγνωριστικό συναλλαγής"; +"customer_center_product_id" = "Αναγνωριστικό προϊόντος"; +"customer_center_store" = "Κατάστημα"; +"customer_center_sandbox" = "Sandbox"; + +/* 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" = "Δεν έχει ρυθμιστεί εφαρμογή αλληλογραφίας σε αυτή τη συσκευή. Μπορείτε να επικοινωνήσετε μαζί μας στο %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 9a9418c643..ecfcd6f2fe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_active_title" = "No subscriptions found"; +"customer_center_no_active_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" = "Manage subscription"; +"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_purchase_date" = "Purchase date"; +"customer_center_expiration_date" = "Expiration date"; + +/* 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_see_all_purchases" = "See all purchases"; +"customer_center_purchase_history" = "Purchase history"; +"customer_center_history_active" = "Active subscriptions"; +"customer_center_history_expired" = "Expired subscriptions"; +"customer_center_history_other" = "Other purchases"; +"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_transaction_id" = "Transaction ID"; +"customer_center_product_id" = "Product ID"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 9a9418c643..ecfcd6f2fe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_active_title" = "No subscriptions found"; +"customer_center_no_active_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" = "Manage subscription"; +"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_purchase_date" = "Purchase date"; +"customer_center_expiration_date" = "Expiration date"; + +/* 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_see_all_purchases" = "See all purchases"; +"customer_center_purchase_history" = "Purchase history"; +"customer_center_history_active" = "Active subscriptions"; +"customer_center_history_expired" = "Expired subscriptions"; +"customer_center_history_other" = "Other purchases"; +"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_transaction_id" = "Transaction ID"; +"customer_center_product_id" = "Product ID"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 9a9418c643..ecfcd6f2fe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Done"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Manage your subscription"; +"customer_center_no_active_title" = "No subscriptions found"; +"customer_center_no_active_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" = "Manage subscription"; +"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_purchase_date" = "Purchase date"; +"customer_center_expiration_date" = "Expiration date"; + +/* 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_see_all_purchases" = "See all purchases"; +"customer_center_purchase_history" = "Purchase history"; +"customer_center_history_active" = "Active subscriptions"; +"customer_center_history_expired" = "Expired subscriptions"; +"customer_center_history_other" = "Other purchases"; +"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_transaction_id" = "Transaction ID"; +"customer_center_product_id" = "Product ID"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index 28671384ee..cbdc5e5265 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Listo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestione su suscripción"; +"customer_center_no_active_title" = "No se encontraron suscripciones"; +"customer_center_no_active_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" = "Gestionar suscripción"; +"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_purchase_date" = "Fecha de compra"; +"customer_center_expiration_date" = "Fecha de caducidad"; + +/* 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_see_all_purchases" = "Ver todas las compras"; +"customer_center_purchase_history" = "Historial de compras"; +"customer_center_history_active" = "Suscripciones activas"; +"customer_center_history_expired" = "Suscripciones caducadas"; +"customer_center_history_other" = "Otras compras"; +"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_transaction_id" = "ID de transacción"; +"customer_center_product_id" = "ID del producto"; +"customer_center_store" = "Tienda"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 82c92669dd..60069c7f78 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Listo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestiona tu suscripción"; +"customer_center_no_active_title" = "No se encontraron suscripciones"; +"customer_center_no_active_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" = "Gestionar suscripción"; +"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_purchase_date" = "Fecha de compra"; +"customer_center_expiration_date" = "Fecha de caducidad"; + +/* 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_see_all_purchases" = "Ver todas las compras"; +"customer_center_purchase_history" = "Historial de compras"; +"customer_center_history_active" = "Suscripciones activas"; +"customer_center_history_expired" = "Suscripciones caducadas"; +"customer_center_history_other" = "Otras compras"; +"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_transaction_id" = "ID de transacción"; +"customer_center_product_id" = "ID del producto"; +"customer_center_store" = "Tienda"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 2248bda7a7..f5d833bbbe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Valmis"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Hallinnoi tilaustasi"; +"customer_center_no_active_title" = "Tilauksia ei löytynyt"; +"customer_center_no_active_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" = "Hallinnoi tilausta"; +"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_purchase_date" = "Ostopäivä"; +"customer_center_expiration_date" = "Vanhenemispäivä"; + +/* 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_see_all_purchases" = "Näytä kaikki ostokset"; +"customer_center_purchase_history" = "Ostohistoria"; +"customer_center_history_active" = "Aktiiviset tilaukset"; +"customer_center_history_expired" = "Vanhentuneet tilaukset"; +"customer_center_history_other" = "Muut ostokset"; +"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_transaction_id" = "Tapahtumatunnus"; +"customer_center_product_id" = "Tuotetunnus"; +"customer_center_store" = "Kauppa"; +"customer_center_sandbox" = "Hiekkalaatikko"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 430b917aa9..756a77dc2e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Terminé"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gérer votre abonnement"; +"customer_center_no_active_title" = "Aucun abonnement trouvé"; +"customer_center_no_active_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" = "Gérer l'abonnement"; +"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_purchase_date" = "Date d'achat"; +"customer_center_expiration_date" = "Date d'expiration"; + +/* 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_see_all_purchases" = "Voir tous les achats"; +"customer_center_purchase_history" = "Historique des achats"; +"customer_center_history_active" = "Abonnements actifs"; +"customer_center_history_expired" = "Abonnements expirés"; +"customer_center_history_other" = "Autres achats"; +"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_transaction_id" = "ID de transaction"; +"customer_center_product_id" = "ID du produit"; +"customer_center_store" = "Boutique"; +"customer_center_sandbox" = "Bac à sable"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 6f8e72d43c..07e6be34cc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Terminé"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gérer votre abonnement"; +"customer_center_no_active_title" = "Aucun abonnement trouvé"; +"customer_center_no_active_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" = "Gérer l'abonnement"; +"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_purchase_date" = "Date d'achat"; +"customer_center_expiration_date" = "Date d'expiration"; + +/* 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_see_all_purchases" = "Voir tous les achats"; +"customer_center_purchase_history" = "Historique des achats"; +"customer_center_history_active" = "Abonnements actifs"; +"customer_center_history_expired" = "Abonnements expirés"; +"customer_center_history_other" = "Autres achats"; +"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_transaction_id" = "ID de transaction"; +"customer_center_product_id" = "ID du produit"; +"customer_center_store" = "Boutique"; +"customer_center_sandbox" = "Bac à sable"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 360c7d5fdc..0d789810d1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "סיום"; + +/* Customer Center – screens */ +"customer_center_management_title" = "ניהול המנוי שלך"; +"customer_center_no_active_title" = "לא נמצאו מנויים"; +"customer_center_no_active_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_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_purchase_date" = "תאריך רכישה"; +"customer_center_expiration_date" = "תאריך תפוגה"; + +/* 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_see_all_purchases" = "הצג את כל הרכישות"; +"customer_center_purchase_history" = "היסטוריית רכישות"; +"customer_center_history_active" = "מנויים פעילים"; +"customer_center_history_expired" = "מנויים שפג תוקפם"; +"customer_center_history_other" = "רכישות אחרות"; +"customer_center_account_details" = "פרטי חשבון"; +"customer_center_user_id" = "מזהה משתמש"; +"customer_center_copy" = "העתקה"; +"customer_center_copied" = "הועתק"; +"customer_center_original_download_date" = "תאריך ההורדה המקורי"; +"customer_center_transaction_id" = "מזהה עסקה"; +"customer_center_product_id" = "מזהה מוצר"; +"customer_center_store" = "חנות"; +"customer_center_sandbox" = "Sandbox"; + +/* 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" = "לא הוגדרה אפליקציית אימייל במכשיר זה. תוכל ליצור איתנו קשר בכתובת %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index 8359155e06..45ea6b6fb8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "पूर्ण"; + +/* Customer Center – screens */ +"customer_center_management_title" = "अपनी सदस्यता प्रबंधित करें"; +"customer_center_no_active_title" = "कोई सदस्यता नहीं मिली"; +"customer_center_no_active_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_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_purchase_date" = "खरीद की तारीख"; +"customer_center_expiration_date" = "समाप्ति तिथि"; + +/* 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_see_all_purchases" = "सभी खरीदारी देखें"; +"customer_center_purchase_history" = "खरीद इतिहास"; +"customer_center_history_active" = "सक्रिय सदस्यताएं"; +"customer_center_history_expired" = "समाप्त हुई सदस्यताएं"; +"customer_center_history_other" = "अन्य खरीदारी"; +"customer_center_account_details" = "खाते का विवरण"; +"customer_center_user_id" = "उपयोगकर्ता आईडी"; +"customer_center_copy" = "कॉपी करें"; +"customer_center_copied" = "कॉपी हो गया"; +"customer_center_original_download_date" = "मूल डाउनलोड तिथि"; +"customer_center_transaction_id" = "लेनदेन आईडी"; +"customer_center_product_id" = "उत्पाद आईडी"; +"customer_center_store" = "स्टोर"; +"customer_center_sandbox" = "सैंडबॉक्स"; + +/* 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" = "इस डिवाइस पर कोई मेल ऐप कॉन्फ़िगर नहीं है। आप हमसे %@ पर संपर्क कर सकते हैं।"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index a6c280cd37..61002a85b0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Upravljanje pretplatom"; +"customer_center_no_active_title" = "Nije pronađena nijedna pretplata"; +"customer_center_no_active_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" = "Upravljanje pretplatom"; +"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_purchase_date" = "Datum kupnje"; +"customer_center_expiration_date" = "Datum isteka"; + +/* 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_see_all_purchases" = "Prikaži sve kupnje"; +"customer_center_purchase_history" = "Povijest kupnji"; +"customer_center_history_active" = "Aktivne pretplate"; +"customer_center_history_expired" = "Istekle pretplate"; +"customer_center_history_other" = "Ostale kupnje"; +"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_transaction_id" = "ID transakcije"; +"customer_center_product_id" = "ID proizvoda"; +"customer_center_store" = "Trgovina"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index 1309af5509..c4effa3edf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Kész"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Előfizetés kezelése"; +"customer_center_no_active_title" = "Nem található előfizetés"; +"customer_center_no_active_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 kezelése"; +"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_purchase_date" = "Vásárlás dátuma"; +"customer_center_expiration_date" = "Lejárat dátuma"; + +/* 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_see_all_purchases" = "Összes vásárlás megtekintése"; +"customer_center_purchase_history" = "Vásárlási előzmények"; +"customer_center_history_active" = "Aktív előfizetések"; +"customer_center_history_expired" = "Lejárt előfizetések"; +"customer_center_history_other" = "Egyéb vásárlások"; +"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_transaction_id" = "Tranzakcióazonosító"; +"customer_center_product_id" = "Termékazonosító"; +"customer_center_store" = "Áruház"; +"customer_center_sandbox" = "Sandbox"; + +/* 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: %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index 6e2fad778e..9b26feeb42 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Selesai"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Kelola langganan Anda"; +"customer_center_no_active_title" = "Tidak ada langganan yang ditemukan"; +"customer_center_no_active_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" = "Kelola langganan"; +"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_purchase_date" = "Tanggal pembelian"; +"customer_center_expiration_date" = "Tanggal kedaluwarsa"; + +/* 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_see_all_purchases" = "Lihat semua pembelian"; +"customer_center_purchase_history" = "Riwayat pembelian"; +"customer_center_history_active" = "Langganan aktif"; +"customer_center_history_expired" = "Langganan berakhir"; +"customer_center_history_other" = "Pembelian lainnya"; +"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_transaction_id" = "ID transaksi"; +"customer_center_product_id" = "ID produk"; +"customer_center_store" = "Toko"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index bed208f7dd..4732c871b4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Fine"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestisci il tuo abbonamento"; +"customer_center_no_active_title" = "Nessun abbonamento trovato"; +"customer_center_no_active_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" = "Gestisci abbonamento"; +"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_purchase_date" = "Data di acquisto"; +"customer_center_expiration_date" = "Data di scadenza"; + +/* 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_see_all_purchases" = "Vedi tutti gli acquisti"; +"customer_center_purchase_history" = "Cronologia acquisti"; +"customer_center_history_active" = "Abbonamenti attivi"; +"customer_center_history_expired" = "Abbonamenti scaduti"; +"customer_center_history_other" = "Altri acquisti"; +"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_transaction_id" = "ID transazione"; +"customer_center_product_id" = "ID prodotto"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 532a154a5c..a99782b9cc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "完了"; + +/* Customer Center – screens */ +"customer_center_management_title" = "サブスクリプションを管理"; +"customer_center_no_active_title" = "サブスクリプションが見つかりません"; +"customer_center_no_active_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_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_purchase_date" = "購入日"; +"customer_center_expiration_date" = "有効期限"; + +/* 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_see_all_purchases" = "すべての購入を見る"; +"customer_center_purchase_history" = "購入履歴"; +"customer_center_history_active" = "有効なサブスクリプション"; +"customer_center_history_expired" = "終了したサブスクリプション"; +"customer_center_history_other" = "その他の購入"; +"customer_center_account_details" = "アカウントの詳細"; +"customer_center_user_id" = "ユーザーID"; +"customer_center_copy" = "コピー"; +"customer_center_copied" = "コピーしました"; +"customer_center_original_download_date" = "初回ダウンロード日"; +"customer_center_transaction_id" = "取引ID"; +"customer_center_product_id" = "製品ID"; +"customer_center_store" = "ストア"; +"customer_center_sandbox" = "Sandbox"; + +/* 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" = "このデバイスにはメールアプリが設定されていません。%@までご連絡ください。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 88ec351edc..afbb5f3541 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "완료"; + +/* Customer Center – screens */ +"customer_center_management_title" = "구독 관리"; +"customer_center_no_active_title" = "구독을 찾을 수 없습니다"; +"customer_center_no_active_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_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_purchase_date" = "구매일"; +"customer_center_expiration_date" = "만료일"; + +/* 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_see_all_purchases" = "모든 구매 항목 보기"; +"customer_center_purchase_history" = "구매 내역"; +"customer_center_history_active" = "활성 구독"; +"customer_center_history_expired" = "만료된 구독"; +"customer_center_history_other" = "기타 구매 항목"; +"customer_center_account_details" = "계정 세부정보"; +"customer_center_user_id" = "사용자 ID"; +"customer_center_copy" = "복사"; +"customer_center_copied" = "복사됨"; +"customer_center_original_download_date" = "최초 다운로드 날짜"; +"customer_center_transaction_id" = "거래 ID"; +"customer_center_product_id" = "제품 ID"; +"customer_center_store" = "스토어"; +"customer_center_sandbox" = "샌드박스"; + +/* 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" = "이 기기에 메일 앱이 설정되어 있지 않습니다. %@로 문의해 주세요."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 97168b3253..60a987fac7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Selesai"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Urus langganan anda"; +"customer_center_no_active_title" = "Tiada langganan ditemui"; +"customer_center_no_active_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" = "Urus langganan"; +"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_purchase_date" = "Tarikh pembelian"; +"customer_center_expiration_date" = "Tarikh 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_see_all_purchases" = "Lihat semua pembelian"; +"customer_center_purchase_history" = "Sejarah pembelian"; +"customer_center_history_active" = "Langganan aktif"; +"customer_center_history_expired" = "Langganan tamat tempoh"; +"customer_center_history_other" = "Pembelian lain"; +"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_transaction_id" = "ID transaksi"; +"customer_center_product_id" = "ID produk"; +"customer_center_store" = "Kedai"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index c19d9ce152..f90b880982 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Ferdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer abonnementet ditt"; +"customer_center_no_active_title" = "Fant ingen abonnementer"; +"customer_center_no_active_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" = "Administrer abonnement"; +"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_purchase_date" = "Kjøpsdato"; +"customer_center_expiration_date" = "Utløpsdato"; + +/* 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_see_all_purchases" = "Se alle kjøp"; +"customer_center_purchase_history" = "Kjøpshistorikk"; +"customer_center_history_active" = "Aktive abonnementer"; +"customer_center_history_expired" = "Utløpte abonnementer"; +"customer_center_history_other" = "Andre kjøp"; +"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_transaction_id" = "Transaksjons-ID"; +"customer_center_product_id" = "Produkt-ID"; +"customer_center_store" = "Butikk"; +"customer_center_sandbox" = "Sandkasse"; + +/* 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å %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index 326532972a..7a50439caa 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gereed"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Beheer uw abonnement"; +"customer_center_no_active_title" = "Geen abonnementen gevonden"; +"customer_center_no_active_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 beheren"; +"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_purchase_date" = "Aankoopdatum"; +"customer_center_expiration_date" = "Vervaldatum"; + +/* 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_see_all_purchases" = "Alle aankopen bekijken"; +"customer_center_purchase_history" = "Aankoopgeschiedenis"; +"customer_center_history_active" = "Actieve abonnementen"; +"customer_center_history_expired" = "Verlopen abonnementen"; +"customer_center_history_other" = "Overige aankopen"; +"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_transaction_id" = "Transactie-ID"; +"customer_center_product_id" = "Product-ID"; +"customer_center_store" = "Store"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index b65eb3c5c5..c537d10cb5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Ferdig"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Administrer abonnementet ditt"; +"customer_center_no_active_title" = "Fant ingen abonnementer"; +"customer_center_no_active_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" = "Administrer abonnement"; +"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_purchase_date" = "Kjøpsdato"; +"customer_center_expiration_date" = "Utløpsdato"; + +/* 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_see_all_purchases" = "Se alle kjøp"; +"customer_center_purchase_history" = "Kjøpshistorikk"; +"customer_center_history_active" = "Aktive abonnementer"; +"customer_center_history_expired" = "Utløpte abonnementer"; +"customer_center_history_other" = "Andre kjøp"; +"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_transaction_id" = "Transaksjons-ID"; +"customer_center_product_id" = "Produkt-ID"; +"customer_center_store" = "Butikk"; +"customer_center_sandbox" = "Sandkasse"; + +/* 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å %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 848e8c1e20..a1e6d30324 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gotowe"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Zarządzaj subskrypcją"; +"customer_center_no_active_title" = "Nie znaleziono subskrypcji"; +"customer_center_no_active_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" = "Zarządzaj subskrypcją"; +"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_purchase_date" = "Data zakupu"; +"customer_center_expiration_date" = "Data wygaśnięcia"; + +/* 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_see_all_purchases" = "Zobacz wszystkie zakupy"; +"customer_center_purchase_history" = "Historia zakupów"; +"customer_center_history_active" = "Aktywne subskrypcje"; +"customer_center_history_expired" = "Wygasłe subskrypcje"; +"customer_center_history_other" = "Inne zakupy"; +"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_transaction_id" = "ID transakcji"; +"customer_center_product_id" = "ID produktu"; +"customer_center_store" = "Sklep"; +"customer_center_sandbox" = "Środowisko testowe"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index e5628fdcd1..6506195c95 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_active_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" = "Gerir subscrição"; +"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_purchase_date" = "Data de compra"; +"customer_center_expiration_date" = "Data de expiração"; + +/* 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_see_all_purchases" = "Ver todas as compras"; +"customer_center_purchase_history" = "Histórico de compras"; +"customer_center_history_active" = "Subscrições ativas"; +"customer_center_history_expired" = "Subscrições expiradas"; +"customer_center_history_other" = "Outras compras"; +"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_transaction_id" = "ID da transação"; +"customer_center_product_id" = "ID do produto"; +"customer_center_store" = "Loja"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index e366bcc29f..d2b3bd64a9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_active_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" = "Gerir subscrição"; +"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_purchase_date" = "Data de compra"; +"customer_center_expiration_date" = "Data de expiração"; + +/* 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_see_all_purchases" = "Ver todas as compras"; +"customer_center_purchase_history" = "Histórico de compras"; +"customer_center_history_active" = "Subscrições ativas"; +"customer_center_history_expired" = "Subscrições expiradas"; +"customer_center_history_other" = "Outras compras"; +"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_transaction_id" = "ID da transação"; +"customer_center_product_id" = "ID do produto"; +"customer_center_store" = "Loja"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index c522b2928d..e447548a38 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Concluído"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gerir a sua subscrição"; +"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; +"customer_center_no_active_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" = "Gerir subscrição"; +"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_purchase_date" = "Data de compra"; +"customer_center_expiration_date" = "Data de expiração"; + +/* 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_see_all_purchases" = "Ver todas as compras"; +"customer_center_purchase_history" = "Histórico de compras"; +"customer_center_history_active" = "Subscrições ativas"; +"customer_center_history_expired" = "Subscrições expiradas"; +"customer_center_history_other" = "Outras compras"; +"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_transaction_id" = "ID da transação"; +"customer_center_product_id" = "ID do produto"; +"customer_center_store" = "Loja"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index bc4ed716ce..0c9b0af95b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Gata"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Gestionați-vă abonamentul"; +"customer_center_no_active_title" = "Nu s-a găsit niciun abonament"; +"customer_center_no_active_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" = "Gestionați abonamentul"; +"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_purchase_date" = "Data achiziției"; +"customer_center_expiration_date" = "Data expirării"; + +/* 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_see_all_purchases" = "Vezi toate achizițiile"; +"customer_center_purchase_history" = "Istoricul achizițiilor"; +"customer_center_history_active" = "Abonamente active"; +"customer_center_history_expired" = "Abonamente expirate"; +"customer_center_history_other" = "Alte achiziții"; +"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_transaction_id" = "ID tranzacție"; +"customer_center_product_id" = "ID produs"; +"customer_center_store" = "Magazin"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index db11b63943..a28bbef470 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Готово"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Управление подпиской"; +"customer_center_no_active_title" = "Подписки не найдены"; +"customer_center_no_active_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_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_purchase_date" = "Дата покупки"; +"customer_center_expiration_date" = "Дата окончания"; + +/* 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_see_all_purchases" = "Показать все покупки"; +"customer_center_purchase_history" = "История покупок"; +"customer_center_history_active" = "Активные подписки"; +"customer_center_history_expired" = "Истёкшие подписки"; +"customer_center_history_other" = "Другие покупки"; +"customer_center_account_details" = "Данные аккаунта"; +"customer_center_user_id" = "ID пользователя"; +"customer_center_copy" = "Копировать"; +"customer_center_copied" = "Скопировано"; +"customer_center_original_download_date" = "Дата первой загрузки"; +"customer_center_transaction_id" = "ID транзакции"; +"customer_center_product_id" = "ID продукта"; +"customer_center_store" = "Магазин"; +"customer_center_sandbox" = "Тестовая среда"; + +/* 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" = "На этом устройстве не настроено почтовое приложение. Вы можете связаться с нами по адресу %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index ed6ad5a6da..a6c7d7cb0e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Hotovo"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Spravovať predplatné"; +"customer_center_no_active_title" = "Nenašlo sa žiadne predplatné"; +"customer_center_no_active_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" = "Spravovať predplatné"; +"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_purchase_date" = "Dátum nákupu"; +"customer_center_expiration_date" = "Dátum vypršania platnosti"; + +/* 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_see_all_purchases" = "Zobraziť všetky nákupy"; +"customer_center_purchase_history" = "História nákupov"; +"customer_center_history_active" = "Aktívne predplatné"; +"customer_center_history_expired" = "Vypršané predplatné"; +"customer_center_history_other" = "Ostatné nákupy"; +"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_transaction_id" = "ID transakcie"; +"customer_center_product_id" = "ID produktu"; +"customer_center_store" = "Obchod"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 020b6e1e8a..78d9dd67da 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Končano"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Upravljanje naročnine"; +"customer_center_no_active_title" = "Ni najdenih naročnin"; +"customer_center_no_active_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" = "Upravljanje naročnine"; +"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_purchase_date" = "Datum nakupa"; +"customer_center_expiration_date" = "Datum poteka"; + +/* 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_see_all_purchases" = "Prikaži vse nakupe"; +"customer_center_purchase_history" = "Zgodovina nakupov"; +"customer_center_history_active" = "Aktivne naročnine"; +"customer_center_history_expired" = "Potekle naročnine"; +"customer_center_history_other" = "Drugi nakupi"; +"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_transaction_id" = "ID transakcije"; +"customer_center_product_id" = "ID izdelka"; +"customer_center_store" = "Trgovina"; +"customer_center_sandbox" = "Peskovnik"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index a5ed2ada4f..e2d5b5ca3d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Klar"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Hantera din prenumeration"; +"customer_center_no_active_title" = "Inga prenumerationer hittades"; +"customer_center_no_active_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" = "Hantera prenumeration"; +"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_purchase_date" = "Inköpsdatum"; +"customer_center_expiration_date" = "Utgångsdatum"; + +/* 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_see_all_purchases" = "Visa alla köp"; +"customer_center_purchase_history" = "Köphistorik"; +"customer_center_history_active" = "Aktiva prenumerationer"; +"customer_center_history_expired" = "Upphörda prenumerationer"; +"customer_center_history_other" = "Andra köp"; +"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_transaction_id" = "Transaktions-ID"; +"customer_center_product_id" = "Produkt-ID"; +"customer_center_store" = "Butik"; +"customer_center_sandbox" = "Sandbox"; + +/* 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å %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index e250920dc6..1d32900ea2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "เสร็จสิ้น"; + +/* Customer Center – screens */ +"customer_center_management_title" = "จัดการการสมัครสมาชิกของคุณ"; +"customer_center_no_active_title" = "ไม่พบการสมัครสมาชิก"; +"customer_center_no_active_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_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_purchase_date" = "วันที่ซื้อ"; +"customer_center_expiration_date" = "วันหมดอายุ"; + +/* 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_see_all_purchases" = "ดูการซื้อทั้งหมด"; +"customer_center_purchase_history" = "ประวัติการซื้อ"; +"customer_center_history_active" = "การสมัครสมาชิกที่ใช้งานอยู่"; +"customer_center_history_expired" = "การสมัครสมาชิกที่หมดอายุ"; +"customer_center_history_other" = "การซื้ออื่นๆ"; +"customer_center_account_details" = "รายละเอียดบัญชี"; +"customer_center_user_id" = "รหัสผู้ใช้"; +"customer_center_copy" = "คัดลอก"; +"customer_center_copied" = "คัดลอกแล้ว"; +"customer_center_original_download_date" = "วันที่ดาวน์โหลดครั้งแรก"; +"customer_center_transaction_id" = "รหัสธุรกรรม"; +"customer_center_product_id" = "รหัสสินค้า"; +"customer_center_store" = "ร้านค้า"; +"customer_center_sandbox" = "แซนด์บ็อกซ์"; + +/* 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" = "ไม่มีแอปอีเมลที่ตั้งค่าไว้บนอุปกรณ์นี้ คุณสามารถติดต่อเราได้ที่ %@"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 39ede98efa..c7ac3aff83 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Bitti"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Aboneliğinizi yönetin"; +"customer_center_no_active_title" = "Abonelik bulunamadı"; +"customer_center_no_active_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 yönet"; +"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_purchase_date" = "Satın alma tarihi"; +"customer_center_expiration_date" = "Son kullanma tarihi"; + +/* 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_see_all_purchases" = "Tüm satın alımları gör"; +"customer_center_purchase_history" = "Satın alma geçmişi"; +"customer_center_history_active" = "Aktif abonelikler"; +"customer_center_history_expired" = "Süresi dolmuş abonelikler"; +"customer_center_history_other" = "Diğer satın alımlar"; +"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_transaction_id" = "İşlem kimliği"; +"customer_center_product_id" = "Ürün kimliği"; +"customer_center_store" = "Mağaza"; +"customer_center_sandbox" = "Sandbox"; + +/* 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."; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 85298a8eb2..a37a04f8a1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "Готово"; + +/* Customer Center – screens */ +"customer_center_management_title" = "Керування підпискою"; +"customer_center_no_active_title" = "Підписок не знайдено"; +"customer_center_no_active_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_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_purchase_date" = "Дата покупки"; +"customer_center_expiration_date" = "Дата закінчення"; + +/* 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_see_all_purchases" = "Переглянути всі покупки"; +"customer_center_purchase_history" = "Історія покупок"; +"customer_center_history_active" = "Активні підписки"; +"customer_center_history_expired" = "Завершені підписки"; +"customer_center_history_other" = "Інші покупки"; +"customer_center_account_details" = "Дані облікового запису"; +"customer_center_user_id" = "Ідентифікатор користувача"; +"customer_center_copy" = "Копіювати"; +"customer_center_copied" = "Скопійовано"; +"customer_center_original_download_date" = "Дата первинного завантаження"; +"customer_center_transaction_id" = "Ідентифікатор транзакції"; +"customer_center_product_id" = "Ідентифікатор товару"; +"customer_center_store" = "Магазин"; +"customer_center_sandbox" = "Тестове середовище"; + +/* 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" = "На цьому пристрої не налаштовано жодного поштового застосунку. Ви можете зв'язатися з нами за адресою %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 98d1ac03e4..5020577a45 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* 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_active_title" = "Không tìm thấy gói đăng ký nào"; +"customer_center_no_active_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" = "Quản lý gói đăng ký"; +"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_purchase_date" = "Ngày mua"; +"customer_center_expiration_date" = "Ngày 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_see_all_purchases" = "Xem tất cả giao dịch mua"; +"customer_center_purchase_history" = "Lịch sử mua hàng"; +"customer_center_history_active" = "Gói đăng ký đang hoạt động"; +"customer_center_history_expired" = "Gói đăng ký đã hết hạn"; +"customer_center_history_other" = "Giao dịch mua khá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_transaction_id" = "ID giao dịch"; +"customer_center_product_id" = "ID sản phẩm"; +"customer_center_store" = "Cửa hàng"; +"customer_center_sandbox" = "Sandbox"; + +/* 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 %@."; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index fdd2d4b7af..19fdcbbcc5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "完成"; + +/* Customer Center – screens */ +"customer_center_management_title" = "管理您的订阅"; +"customer_center_no_active_title" = "未找到订阅"; +"customer_center_no_active_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_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_purchase_date" = "购买日期"; +"customer_center_expiration_date" = "到期日期"; + +/* 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_see_all_purchases" = "查看所有购买项目"; +"customer_center_purchase_history" = "购买记录"; +"customer_center_history_active" = "有效订阅"; +"customer_center_history_expired" = "已过期订阅"; +"customer_center_history_other" = "其他购买项目"; +"customer_center_account_details" = "账户详情"; +"customer_center_user_id" = "用户 ID"; +"customer_center_copy" = "复制"; +"customer_center_copied" = "已复制"; +"customer_center_original_download_date" = "首次下载日期"; +"customer_center_transaction_id" = "交易 ID"; +"customer_center_product_id" = "产品 ID"; +"customer_center_store" = "商店"; +"customer_center_sandbox" = "沙盒环境"; + +/* 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" = "此设备未配置邮件应用。您可以通过 %@ 联系我们。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index c11762ea5e..8d2a930c01 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -36,3 +36,101 @@ /* Generic alert dismiss button */ "alert_action_done" = "完成"; + +/* Customer Center – screens */ +"customer_center_management_title" = "管理您的訂閱"; +"customer_center_no_active_title" = "找不到訂閱"; +"customer_center_no_active_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_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_purchase_date" = "購買日期"; +"customer_center_expiration_date" = "到期日期"; + +/* 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_see_all_purchases" = "查看所有購買項目"; +"customer_center_purchase_history" = "購買記錄"; +"customer_center_history_active" = "有效訂閱"; +"customer_center_history_expired" = "已過期訂閱"; +"customer_center_history_other" = "其他購買項目"; +"customer_center_account_details" = "帳戶詳情"; +"customer_center_user_id" = "使用者 ID"; +"customer_center_copy" = "複製"; +"customer_center_copied" = "已複製"; +"customer_center_original_download_date" = "首次下載日期"; +"customer_center_transaction_id" = "交易 ID"; +"customer_center_product_id" = "產品 ID"; +"customer_center_store" = "商店"; +"customer_center_sandbox" = "沙盒環境"; + +/* 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" = "此裝置未設定郵件應用程式。您可以透過 %@ 與我們聯絡。"; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 60412584bf..184f0f32b9 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -422,6 +422,7 @@ 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 */; }; @@ -811,6 +812,7 @@ 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 = ""; }; 59C73BC10AC2F6DE8AB1074A /* SuperwallKit_AppleIncRootCertificate.cer */ = {isa = PBXFileReference; path = SuperwallKit_AppleIncRootCertificate.cer; sourceTree = ""; }; 5A413B6FF46B130D90A428B4 /* ProductPurchaserLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserLogic.swift; sourceTree = ""; }; @@ -1808,6 +1810,7 @@ isa = PBXGroup; children = ( 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, + 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */, 9723663065538DB5CF16F4A4 /* Actions */, 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, @@ -3418,6 +3421,7 @@ D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, + BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, 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/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index 2ce75e0c15..f3396198be 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -142,4 +142,19 @@ struct PurchasePresentationBuilderTests { 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"]) + } } From 6f63e9a1650d6830133f740209a31e5867048e45 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 16:25:31 -0500 Subject: [PATCH 10/42] feat(customer-center): add view-model dependencies and live adapters Co-Authored-By: Claude Fable 5 --- .../CustomerCenterDependencies.swift | 173 ++++++++++++++++++ .../Network/Device Helper/DeviceHelper.swift | 3 + SuperwallKit.xcodeproj/project.pbxproj | 28 +++ .../MockSkProduct.swift | 14 +- .../CustomerCenterDependenciesMocks.swift | 108 +++++++++++ .../CustomerCenterDependenciesTests.swift | 54 ++++++ 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift new file mode 100644 index 0000000000..ad2a051fd8 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -0,0 +1,173 @@ +// +// CustomerCenterDependencies.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + +import Combine +import Foundation +import UIKit + +protocol CustomerCenterCustomerInfoProviding: AnyObject { + func fetchCustomerInfo() 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 +} + +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 { + init(_ product: StoreProduct) { + var title = product.productIdentifier + if #available(iOS 15.0, *), let name = product.sk2Product?.displayName, !name.isEmpty { + title = name + } else if let name = product.sk1Product?.localizedTitle, !name.isEmpty { + title = name + } + var isAutoRenewable: Bool? + if #available(iOS 15.0, *), let type = product.sk2Product?.type { + isAutoRenewable = type == .autoRenewable + } + self.init( + productId: product.productIdentifier, + title: title, + 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() } + var customerInfoPublisher: AnyPublisher { Superwall.shared.$customerInfo.eraseToAnyPublisher() } +} +@available(iOS 15.0, *) +struct LiveProductsProvider: CustomerCenterProductsProviding { + func products(for ids: Set) async -> [String: ProductDisplayInfo] { + guard !ids.isEmpty else { return [:] } + let products = await Superwall.shared.products(for: ids) + return Dictionary(uniqueKeysWithValues: products.map { ($0.productIdentifier, ProductDisplayInfo($0)) }) + } +} +@available(iOS 15.0, *) +struct LiveRestorer: CustomerCenterRestoring { + func restorePurchases() async -> RestorationResult { await Superwall.shared.restorePurchases() } +} +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 } + var isSandbox: Bool { ReceiptManager.isSandboxEnvironment ?? false } + 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(), + restore: LiveRestorer(), + urlOpener: LiveURLOpener(), + tracker: LiveEventTracker(), + environment: LiveEnvironment(container: container, webManagementOverride: configuration.support.webManagementURL), + transactionLookup: StoreKitTransactionLookup() + ) + } +} + +enum RuntimeUtils { + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } +} 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 184f0f32b9..6039b87785 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -207,6 +207,7 @@ 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 */; }; @@ -357,6 +358,7 @@ 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 */; }; A51060CF6339BF9383F94B51 /* MockSubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */; }; A59E22688D68CBE09FF78D57 /* IntroOfferEligibilityRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08AEAA8E3B5F51848523AE61 /* IntroOfferEligibilityRequest.swift */; }; @@ -445,6 +447,7 @@ 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 */; }; @@ -952,6 +955,7 @@ 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 = ""; }; @@ -1053,6 +1057,7 @@ B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawPaywallResponse.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 = ""; }; @@ -1227,6 +1232,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 = ""; }; @@ -1814,6 +1820,7 @@ 9723663065538DB5CF16F4A4 /* Actions */, 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, + B5DA90160501C06A71BE97C5 /* ViewModel */, ); path = CustomerCenter; sourceTree = ""; @@ -2214,6 +2221,14 @@ path = Alert; sourceTree = ""; }; + 6CA03A908C710F4F27075427 /* ViewModel */ = { + isa = PBXGroup; + children = ( + 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; 6F9276EC956CE4A6C09949CE /* Delegates */ = { isa = PBXGroup; children = ( @@ -2778,6 +2793,15 @@ path = "Custom URL Session"; sourceTree = ""; }; + B5DA90160501C06A71BE97C5 /* ViewModel */ = { + isa = PBXGroup; + children = ( + B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */, + FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; B95C41E4499A61EDED234DEF /* Migration */ = { isa = PBXGroup; children = ( @@ -3080,6 +3104,7 @@ 4AC7FD1A50349966FF78DB51 /* Actions */, 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, + 6CA03A908C710F4F27075427 /* ViewModel */, 1422D4F63A53E2768C2E90E6 /* Views */, ); path = CustomerCenter; @@ -3419,6 +3444,8 @@ 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */, 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */, D163B7AB99BE796B233DAE28 /* CustomerCenterConfigurationTests.swift in Sources */, + C6CC0FF052FE616DB8908757 /* CustomerCenterDependenciesMocks.swift in Sources */, + 5D1F0BE78AFD0801B6073B4A /* CustomerCenterDependenciesTests.swift in Sources */, 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, @@ -3609,6 +3636,7 @@ B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */, BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, + A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */, 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/MockSkProduct.swift b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/MockSkProduct.swift index b4cd718d66..5b4f4f0602 100644 --- a/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/MockSkProduct.swift +++ b/Tests/SuperwallKitTests/Analytics/Trigger Session Manager/MockSkProduct.swift @@ -14,6 +14,7 @@ final class MockSkProduct: SKProduct { private let internalSubscriptionPeriod: SKProductSubscriptionPeriod? private let internalProductIdentifier: String? private let internalSubscriptionGroupIdentifier: String? + private let internalLocalizedTitle: String? override var productIdentifier: String { return internalProductIdentifier ?? super.productIdentifier @@ -38,17 +39,28 @@ final class MockSkProduct: SKProduct { return internalSubscriptionGroupIdentifier ?? super.subscriptionGroupIdentifier } + /// Not chained to `super.localizedTitle`, unlike the other overrides: the underlying + /// `SKProduct` backing store is never populated for a synthetic instance like this one, and + /// (unlike the optional properties above) a crash reading that unset non-optional String isn't + /// worth risking just to reproduce "no title" — defaulting straight to `""` gets the same + /// observable result safely. + override var localizedTitle: String { + return internalLocalizedTitle ?? "" + } + init( subscriptionPeriod: SKProductSubscriptionPeriod? = nil, productIdentifier: String? = nil, introPeriod: MockIntroductoryPeriod? = nil, subscriptionGroupIdentifier: String? = nil, - price: NSDecimalNumber? = nil + price: NSDecimalNumber? = nil, + localizedTitle: String? = nil ) { self.internalSubscriptionPeriod = subscriptionPeriod self.internalProductIdentifier = productIdentifier self.internalIntroPeriod = introPeriod self.internalSubscriptionGroupIdentifier = subscriptionGroupIdentifier self.internalPrice = price + self.internalLocalizedTitle = localizedTitle } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift new file mode 100644 index 0000000000..87afcd5724 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift @@ -0,0 +1,108 @@ +// +// 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 + init(_ info: CustomerInfo) { subject = .init(info) } + func fetchCustomerInfo() async -> CustomerInfo { fetchCount += 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 + } +} +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() + ) -> (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 + ) + 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..84e7dc141f --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift @@ -0,0 +1,54 @@ +// +// 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) + } + + @Test("ProductDisplayInfo init: title falls back to the product identifier 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 == "monthly") + } +} From d3dea04afa53f0c615b624ba23d4a4b7fc8f812c Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 16:52:16 -0500 Subject: [PATCH 11/42] feat(customer-center): add CustomerCenterViewModel Co-Authored-By: Claude Fable 5 --- .../Models/CustomerCenterScreenState.swift | 42 +++ .../ViewModel/CustomerCenterViewModel.swift | 291 ++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 12 + .../CustomerCenterViewModelTests.swift | 242 +++++++++++++++ 4 files changed, 587 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift new file mode 100644 index 0000000000..ee15055798 --- /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, noActive } +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 purchaseHistory + case noMailApp(email: String) + + 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 .purchaseHistory: return "history" + case .noMailApp: return "nomail" + } + } +} + +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/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift new file mode 100644 index 0000000000..33154015cf --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -0,0 +1,291 @@ +// +// 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 selectedPurchaseId: String? + @Published var sheet: CustomerCenterSheet? + @Published var restoreState: CustomerCenterRestoreState = .idle + @Published private(set) var refundResult: (productId: String, status: CustomerCenterRefundStatus)? + @Published private(set) var showsUpdateBanner = false + @Published private(set) var showsDuplicateBanner = false + + let configuration: CustomerCenterConfiguration + let strings: CustomerCenterStrings + var callbacks = CustomerCenterCallbacks() + var presentationMode = "sheet" + private(set) var pendingSurvey: PendingSurvey? + + private let dependencies: CustomerCenterDependencies + private let isChangePlanSheetAvailable: Bool + private var products: [String: ProductDisplayInfo] = [:] + private var familyShared: Set = [] + private var pendingAction: PendingAction? + private var updateWarningDismissed = false + private var hasTrackedOpen = false + private var didDismiss = false + private var cancellables = Set() + + init( + configuration: CustomerCenterConfiguration, + dependencies: CustomerCenterDependencies, + strings: CustomerCenterStrings, + isChangePlanSheetAvailable: Bool? = nil + ) { + self.configuration = configuration + self.dependencies = dependencies + self.strings = strings + 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_active") + ) + } + } + + 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) + purchases = builder.build(customerInfo: customerInfo, products: products) + state = hasAnyPurchases(customerInfo) ? .management : .noActive + showsUpdateBanner = !updateWarningDismissed + && configuration.support.shouldWarnToUpdate + && AppVersionComparator.isInstalledVersion( + dependencies.environment.appVersion, + olderThan: configuration.support.latestAppVersion + ) + 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 selectedPurchase: PurchasePresentation? { purchases.first { $0.id == selectedPurchaseId } } + + var userId: String { dependencies.environment.userId } + var originalDownloadDate: Date? { dependencies.environment.originalDownloadDate } + var appStoreURL: URL? { dependencies.environment.appStoreURL } + + 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 + let active = purchases.filter(\.isActive).compactMap(\.productId) + return .init( + userId: env.userId, + appVersion: env.appVersion, + osVersion: env.osVersion, + deviceModel: env.deviceModel, + sdkVersion: env.sdkVersion, + activeEntitlementIds: active, + isSandbox: env.isSandbox + ) + } + + private var supportEmailAvailable: Bool { + guard let url = supportMailtoURL else { return false } + return dependencies.urlOpener.canOpen(url) || dependencies.environment.isSimulator + } + + func paths(for purchase: PurchasePresentation?) -> [ResolvedPath] { + let screen = state == .noActive ? configuration.noActiveScreen : 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 + ) + 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 { + 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 + sheet = nil + await perform(pendingAction.resolved, purchase: pendingAction.purchase) + } + + func cancelSurvey() { + pendingSurvey = nil + pendingAction = 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 .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 } + 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 the manage-subscriptions or change-plan sheet closes; reloads to pick up changes. + func sheetDidDismiss() async { + let info = await dependencies.customerInfo.fetchCustomerInfo() + await apply(customerInfo: info, refetchProducts: true) + } + + func continueAfterUpdateWarning() { + updateWarningDismissed = true + showsUpdateBanner = false + } + + func dismiss() { + guard !didDismiss else { return } + didDismiss = true + callbacks.didDismiss?() + Task { await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterClose()) } + } + + // swiftlint:disable:next large_tuple + func historySections() -> ( + active: [PurchasePresentation], + expired: [PurchasePresentation], + other: [PurchasePresentation] + ) { + let subs = purchases.filter { $0.subscription != nil } + return (subs.filter(\.isActive), subs.filter { !$0.isActive }, purchases.filter { $0.subscription == nil }) + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 6039b87785..33c17f0677 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ 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 */; }; + 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */; }; 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; 498C546594CF7A5DA78575AA /* ReceiptManagerTrialEligibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */; }; 49A7156A67C8BAB23F97EC39 /* EmailTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6BF63B250AE0D83DECFCD0 /* EmailTests.swift */; }; @@ -334,6 +335,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 */; }; @@ -509,6 +511,7 @@ 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 */; }; @@ -662,6 +665,7 @@ 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; 15E6FBB3D0826827A04F87AE /* EndpointKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointKind.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 = ""; }; @@ -796,6 +800,7 @@ 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 = ""; }; + 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 = ""; }; @@ -817,6 +822,7 @@ 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 = ""; }; @@ -2225,6 +2231,7 @@ isa = PBXGroup; children = ( 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, + 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -2691,6 +2698,7 @@ 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */, 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */, + 51B5BF7B93E59438467DB6C7 /* CustomerCenterScreenState.swift */, 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */, ); path = Models; @@ -2798,6 +2806,7 @@ children = ( B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */, FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */, + 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */, ); path = ViewModel; sourceTree = ""; @@ -3449,6 +3458,7 @@ 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, + DC3ECD6BD248CCA5322CE05E /* CustomerCenterViewModelTests.swift in Sources */, 37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */, 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, @@ -3638,7 +3648,9 @@ 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */, 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, + 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, + 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, C5EA22647EFADC126DC4BFE8 /* Date+IsoString.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift new file mode 100644 index 0000000000..72468a83d9 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -0,0 +1,242 @@ +// +// 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 → noActive") + func loadNoActive() async { + let (vm, _, _) = make(info: info([])) + await vm.load() + #expect(vm.state == .noActive) + } + + @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) + #expect(vm.sheet == .manageSubscriptions(groupId: "g1")) + + vm.cancelSurvey() + #expect(vm.pendingSurvey == 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 == .noActive) + 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, openMethod: .external)), + .init(id: "in", type: .url(inApp, 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 + // default manage path has a survey; answer it + await vm.select(manage, purchase: purchase) + await vm.answerSurvey(optionId: "dont_use") + #expect(vm.sheet == .safari(url)) + } + + @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 == .noActive) + infoMock.subject.value = info([sub()]) + try? await Task.sleep(nanoseconds: 100_000_000) + #expect(vm.state == .management) + var dismissed = false + vm.callbacks.didDismiss = { dismissed = true } + vm.dismiss() + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(dismissed) + let hasCloseEvent: Bool + if case .customerCenterClose = tracker.events.last { + hasCloseEvent = true + } else { + hasCloseEvent = false + } + #expect(hasCloseEvent) + } +} From 79feed662f12eb7deea21239f4729806df480a77 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 17:31:48 -0500 Subject: [PATCH 12/42] feat(customer-center): add core SwiftUI views and StoreKit sheets Co-Authored-By: Claude Fable 5 --- .../CustomerCenterManager.swift | 27 ++++ .../Views/CustomerCenterEnvironment.swift | 64 +++++++++ .../Views/CustomerCenterSheets.swift | 132 ++++++++++++++++++ .../Views/CustomerCenterStubs.swift | 47 +++++++ .../Views/CustomerCenterView.swift | 132 ++++++++++++++++++ .../Views/ManagementScreenView.swift | 91 ++++++++++++ .../Views/NoActiveScreenView.swift | 34 +++++ .../CustomerCenter/Views/PathsListView.swift | 54 +++++++ .../Views/PurchaseCardView.swift | 75 ++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 48 +++++++ .../Views/CustomerCenterViewSmokeTests.swift | 53 +++++++ 11 files changed, 757 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift new file mode 100644 index 0000000000..6c640cdcdf --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -0,0 +1,27 @@ +// +// CustomerCenterManager.swift +// +// +// Created by Claude on 20/08/2026. +// + +import Foundation + +/// Builds the dependencies and view model backing ``CustomerCenterView``. +/// +/// This file currently holds just the static factory `CustomerCenterView` needs. It is expanded +/// with the full public presentation API in a later commit. +@available(iOS 15.0, *) +@MainActor +enum CustomerCenterManager { + static func makeViewModel(configuration: CustomerCenterConfiguration?) -> CustomerCenterViewModel { + let container = Superwall.shared.dependencyContainer + let resolved = configuration ?? container.configManager.options.customerCenter + let dependencies = CustomerCenterDependencies.live(container: container, configuration: resolved) + return CustomerCenterViewModel( + configuration: resolved, + dependencies: dependencies, + strings: .bundled() + ) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift new file mode 100644 index 0000000000..aa839142c7 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift @@ -0,0 +1,64 @@ +// +// CustomerCenterEnvironment.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct CustomerCenterTheme { + var accent: Color? + var background: Color? + var text: Color? + var buttonText: Color? + var buttonBackground: 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) + background = color(appearance.background) + text = color(appearance.text) + buttonText = color(appearance.buttonText) + buttonBackground = color(appearance.buttonBackground) + } +} + +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/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift new file mode 100644 index 0000000000..e70597612a --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -0,0 +1,132 @@ +// +// CustomerCenterSheets.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SafariServices +import StoreKit +import SwiftUI + +@available(iOS 15.0, *) +extension View { + func customerCenterSheets(viewModel: CustomerCenterViewModel) -> some View { + modifier(CustomerCenterSheetsModifier(viewModel: viewModel)) + } +} + +@available(iOS 15.0, *) +private struct CustomerCenterSheetsModifier: ViewModifier { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + + private var isManagePresented: Binding { + .init( + get: { if case .manageSubscriptions = viewModel.sheet { return true } else { return false } }, + set: { if !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } } + ) + } + private var refundBinding: Binding { + .init( + get: { if case .refund = viewModel.sheet { return true } else { return false } }, + set: { if !$0, case .refund = viewModel.sheet { viewModel.sheet = nil } } + ) + } + private var itemSheet: Binding { + .init( + get: { + switch viewModel.sheet { + case .survey, .changePlan, .safari, .purchaseHistory, .noMailApp: 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 { + { 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 .purchaseHistory: + NavigationView { PurchaseHistoryView(viewModel: viewModel) } + case .noMailApp(let email): + Text(strings.string("customer_center_no_mail_app", email)).padding() + default: + EmptyView() + } + } + } +} + +@available(iOS 15.0, *) +private struct ManageSubscriptionsSheet: ViewModifier { + let isPresented: Binding + let groupId: String? + func body(content: Content) -> some View { + if #available(iOS 17.0, *), let groupId { + 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/CustomerCenterStubs.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift new file mode 100644 index 0000000000..ea9317513c --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift @@ -0,0 +1,47 @@ +// +// CustomerCenterStubs.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +// Stub — implemented in a later commit (Task 13: survey, history, account details, +// update banner, duplicate banner, restore overlay). These exist only so +// CustomerCenterView and friends build and the Task 12 smoke test passes. + +@available(iOS 15.0, *) +struct RestoreOverlay: View { + @ObservedObject var viewModel: CustomerCenterViewModel + var body: some View { EmptyView() } +} + +@available(iOS 15.0, *) +struct AppUpdateWarningView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + var body: some View { Section { EmptyView() } } +} + +@available(iOS 15.0, *) +struct DuplicateSubscriptionBanner: View { + var body: some View { Section { EmptyView() } } +} + +@available(iOS 15.0, *) +struct FeedbackSurveyView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + var body: some View { EmptyView() } +} + +@available(iOS 15.0, *) +struct PurchaseHistoryView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + var body: some View { EmptyView() } +} + +@available(iOS 15.0, *) +struct AccountDetailsSection: View { + @ObservedObject var viewModel: CustomerCenterViewModel + var body: some View { Section { EmptyView() } } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift new file mode 100644 index 0000000000..10d8e4582d --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -0,0 +1,132 @@ +// +// CustomerCenterView.swift +// +// +// Created by Claude 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 + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + /// 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 + ) { + let model = CustomerCenterManager.makeViewModel(configuration: configuration) + model.presentationMode = navigationOptions.usesExistingNavigation ? "embedded" : "sheet" + _viewModel = StateObject(wrappedValue: model) + self.navigationOptions = navigationOptions + } + + init(viewModel: CustomerCenterViewModel, navigationOptions: CustomerCenterNavigationOptions) { + _viewModel = StateObject(wrappedValue: viewModel) + self.navigationOptions = navigationOptions + } + + public var body: some View { + Group { + if navigationOptions.usesExistingNavigation { + content + } else { + NavigationView { content }.navigationViewStyle(.stack) + } + } + .environment(\.customerCenterStrings, viewModel.strings) + .environment(\.customerCenterTheme, theme) + .task { await viewModel.load() } + .onDisappear { viewModel.dismiss() } + } + + 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. + @ViewBuilder + private var screenContent: some View { + if navigationOptions.showsCloseButton { + coreContent.toolbar { closeButtonToolbarItem } + } else { + coreContent + } + } + + private var coreContent: some View { + ZStack { + switch viewModel.state { + case .loading: + ProgressView().accessibilityIdentifier("customer_center.loading") + case .management: + ManagementScreenView(viewModel: viewModel) + case .noActive: + NoActiveScreenView(viewModel: viewModel) + } + RestoreOverlay(viewModel: viewModel) + } + } + + 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? { + guard let pair = viewModel.configuration.appearance.accent else { return nil } + let hex = colorScheme == .dark ? pair.dark : pair.light + return UIColor(hex: hex).map(Color.init) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift new file mode 100644 index 0000000000..8fc7503f8f --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -0,0 +1,91 @@ +// +// ManagementScreenView.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct ManagementScreenView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + + private var subscriptions: [PurchasePresentation] { viewModel.purchases.filter { $0.subscription != nil } } + private var others: [PurchasePresentation] { viewModel.purchases.filter { $0.subscription == nil } } + private var isSingle: Bool { viewModel.purchases.count == 1 } + + var body: some View { + List { + if viewModel.showsUpdateBanner { + AppUpdateWarningView(viewModel: viewModel) + } + if viewModel.showsDuplicateBanner { + DuplicateSubscriptionBanner() + } + if !subscriptions.isEmpty { + Section(strings.string("customer_center_section_subscriptions")) { + ForEach(subscriptions) { purchase in + if isSingle { + PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) + } else { + NavigationLink { + PurchaseDetailScreenView(viewModel: viewModel, purchase: purchase) + } label: { + PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) + } + } + } + } + } + if !others.isEmpty { + Section(strings.string("customer_center_section_purchases")) { + ForEach(others.prefix(2)) { PurchaseCardView(purchase: $0, refundResult: nil) } + } + } + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: isSingle ? viewModel.purchases.first : nil) + } + if viewModel.configuration.showsPurchaseHistory { + Section { + NavigationLink(strings.string("customer_center_see_all_purchases")) { + PurchaseHistoryView(viewModel: viewModel) + } + .accessibilityIdentifier("customer_center.purchase_history") + } + } + if viewModel.configuration.showsAccountDetails { + AccountDetailsSection(viewModel: viewModel) + } + } + .listStyle(.insetGrouped) + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + } + + private var navigationTitle: String { + viewModel.configuration.managementScreen.title ?? strings.string("customer_center_management_title") + } +} + +/// Detail for one purchase when the user has several. +@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) } + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: purchase) + } + } + .listStyle(.insetGrouped) + .navigationTitle(purchase.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { viewModel.selectedPurchaseId = purchase.id } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift new file mode 100644 index 0000000000..b2b9d7b201 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift @@ -0,0 +1,34 @@ +// +// NoActiveScreenView.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct NoActiveScreenView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(viewModel.configuration.noActiveScreen.title ?? strings.string("customer_center_no_active_title")) + .font(.headline) + Text(viewModel.configuration.noActiveScreen.subtitle ?? strings.string("customer_center_no_active_subtitle")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .accessibilityIdentifier("customer_center.no_active") + } + 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..7b2355f656 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -0,0 +1,54 @@ +// +// PathsListView.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct PathsListView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + let purchase: PurchasePresentation? + @Environment(\.customerCenterStrings) private var strings + @State private var loadingPathId: String? + + var body: some View { + ForEach(viewModel.paths(for: purchase)) { resolved in + Button { + guard loadingPathId == nil else { return } + loadingPathId = resolved.id + Task { + await viewModel.select(resolved, purchase: purchase) + loadingPathId = nil + } + } label: { + HStack { + Text(title(for: resolved.path)) + Spacer() + if loadingPathId == resolved.id { + ProgressView() + } else { + Image(systemName: "chevron.right").foregroundStyle(.tertiary) + } + } + } + .disabled(loadingPathId != nil) + .accessibilityIdentifier("customer_center.path.\(resolved.id)") + } + } + + private func title(for path: CustomerCenterConfiguration.Path) -> String { + if let title = path.title { return title } + switch path.type { + case .restore: return strings.string("customer_center_path_restore") + case .manageSubscription: return 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 url, _): return url.host ?? url.absoluteString + 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..8e030c6e94 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift @@ -0,0 +1,75 @@ +// +// PurchaseCardView.swift +// +// +// Created by Claude 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 { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(purchase.title).font(.headline) + Spacer() + BadgeView(badge: purchase.badge) + } + 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) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("customer_center.purchase.\(purchase.productId ?? purchase.id)") + } +} + +@available(iOS 15.0, *) +struct BadgeView: View { + let badge: PurchaseBadge + @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("customer_center.badge.\(key)") + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 33c17f0677..9ee8ae3aae 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -42,6 +42,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 */; }; + 1058373F886FEBA381C4B1E8 /* NoActiveScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10863EC29BADB2823086E14D /* NoActiveScreenView.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 */; }; @@ -90,12 +91,14 @@ 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655E5AE73EF5723A28D2EADD /* SurveyTests.swift */; }; 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E541F079BC78206BC44D6E /* TemplateLogic.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 */; }; 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,6 +116,7 @@ 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 */; }; @@ -133,6 +137,7 @@ 3824D48F8E0AF35EEBED8FF4 /* PaywallViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */; }; 3860BFEF3E9A4F76A90480F1 /* TestModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF301A818CE83F4EBB3A7003 /* TestModeManager.swift */; }; 38A74D299EDFCA3F0A5261B7 /* TransactionErrorLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A719AD2F4475F83DFC9575 /* TransactionErrorLogic.swift */; }; + 397D038B9C29E1101048D4BA /* CustomerCenterStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */; }; 3A4A22150A6EB1C234BAC722 /* TestModeRestoreDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0325FB47A06456B909BCB5 /* TestModeRestoreDrawer.swift */; }; 3B6322A7C3392F4729E00B70 /* SK1StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99DE58E263F7AE44DDB6BD52 /* SK1StoreProductDiscount.swift */; }; 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */; }; @@ -228,6 +233,7 @@ 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 */; }; @@ -279,6 +285,7 @@ 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 */; }; + 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 */; }; @@ -325,6 +332,7 @@ 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 */; }; @@ -508,6 +516,7 @@ 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 */; }; @@ -585,6 +594,7 @@ 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 */; }; @@ -657,6 +667,7 @@ 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ObserverModePurchaseDetector.swift; sourceTree = ""; }; 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2Tests.swift; sourceTree = ""; }; 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; + 10863EC29BADB2823086E14D /* NoActiveScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoActiveScreenView.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 = ""; }; @@ -664,6 +675,7 @@ 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 = ""; }; @@ -729,6 +741,7 @@ 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 = ""; }; @@ -776,6 +789,7 @@ 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 = ""; }; + 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStubs.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 = ""; }; 481D47E5121C521DDA268609 /* TriggerRule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerRule.swift; sourceTree = ""; }; @@ -792,6 +806,7 @@ 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 = ""; }; @@ -800,6 +815,7 @@ 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 = ""; }; @@ -888,6 +904,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 = ""; }; @@ -980,6 +997,7 @@ 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 = ""; }; @@ -1010,6 +1028,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 = ""; }; @@ -1046,6 +1065,7 @@ 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 = ""; }; B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerFactory.swift; sourceTree = ""; }; B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallOptions.swift; sourceTree = ""; }; @@ -1310,6 +1330,14 @@ path = "Custom URL Session"; sourceTree = ""; }; + 0885E36F54C6369D2E5FCDC7 /* Views */ = { + isa = PBXGroup; + children = ( + 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, + ); + path = Views; + sourceTree = ""; + }; 0D4F1B49114819E9E6923508 /* Product Fetching */ = { isa = PBXGroup; children = ( @@ -1395,7 +1423,15 @@ 1422D4F63A53E2768C2E90E6 /* Views */ = { isa = PBXGroup; children = ( + 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */, + 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */, 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */, + 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */, + 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */, + B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */, + 10863EC29BADB2823086E14D /* NoActiveScreenView.swift */, + 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */, + A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */, ); path = Views; sourceTree = ""; @@ -1827,6 +1863,7 @@ 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, B5DA90160501C06A71BE97C5 /* ViewModel */, + 0885E36F54C6369D2E5FCDC7 /* Views */, ); path = CustomerCenter; sourceTree = ""; @@ -3110,6 +3147,7 @@ E4455CBE23BD58AF980439B4 /* CustomerCenter */ = { isa = PBXGroup; children = ( + 165B7C00C437146FB5C9DB92 /* CustomerCenterManager.swift */, 4AC7FD1A50349966FF78DB51 /* Actions */, 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, @@ -3459,6 +3497,7 @@ F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.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 */, @@ -3647,9 +3686,14 @@ BAD2C927523B12E973186C6B /* CustomerCenterConfiguration+ObjC.swift in Sources */, 57B142D37BC344DC595E7327 /* CustomerCenterConfiguration.swift in Sources */, A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */, + D99C565B5803B6ECE29A3D8B /* CustomerCenterEnvironment.swift in Sources */, + 295EF01B171923E20329DF91 /* CustomerCenterManager.swift in Sources */, 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */, + 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, + 397D038B9C29E1101048D4BA /* CustomerCenterStubs.swift in Sources */, + FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, @@ -3761,10 +3805,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 */, + 1058373F886FEBA381C4B1E8 /* NoActiveScreenView.swift in Sources */, 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */, 753FBF77D03B954DCE963A52 /* NotificationProtocols.swift in Sources */, 0B5A0C6EA2D1C98B32110FD9 /* NotificationScheduler.swift in Sources */, @@ -3775,6 +3821,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 */, @@ -3849,6 +3896,7 @@ 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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift new file mode 100644 index 0000000000..2410d4873b --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -0,0 +1,53 @@ +// +// CustomerCenterViewSmokeTests.swift +// +// +// Created by Claude 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 + } + } +} From 3616997e660ecfce6179908e161b850a60519875 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 17:46:51 -0500 Subject: [PATCH 13/42] feat(customer-center): add survey, history, account, update, duplicate and restore views Co-Authored-By: Claude Fable 5 --- .../Views/AccountDetailsSection.swift | 40 +++++++++ .../Views/AppUpdateWarningView.swift | 35 ++++++++ .../Views/CustomerCenterSheets.swift | 5 +- .../Views/CustomerCenterStubs.swift | 47 ---------- .../Views/CustomerCenterView.swift | 6 +- .../Views/DuplicateSubscriptionBanner.swift | 24 ++++++ .../Views/FeedbackSurveyView.swift | 56 ++++++++++++ .../Views/PurchaseCardView.swift | 5 +- .../Views/PurchaseHistoryView.swift | 86 +++++++++++++++++++ .../CustomerCenter/Views/RestoreOverlay.swift | 64 ++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 28 +++++- .../Views/CustomerCenterViewSmokeTests.swift | 38 ++++++++ 12 files changed, 375 insertions(+), 59 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift delete mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift new file mode 100644 index 0000000000..17843dfe9e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift @@ -0,0 +1,40 @@ +// +// AccountDetailsSection.swift +// +// +// Created by Claude 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..ae12561083 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift @@ -0,0 +1,35 @@ +// +// AppUpdateWarningView.swift +// +// +// Created by Claude 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 + + 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")) { viewModel.continueAfterUpdateWarning() } + .buttonStyle(.bordered) + .accessibilityIdentifier("customer_center.update_continue") + } + } + .padding(.vertical, 4) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index e70597612a..f3cfce5ca1 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -57,7 +57,10 @@ private struct CustomerCenterSheetsModifier: ViewModifier { return "" } private var onItemSheetDismiss: () -> Void { - { Task { await viewModel.sheetDidDismiss() } } + { + if viewModel.pendingSurvey != nil { viewModel.cancelSurvey() } + Task { await viewModel.sheetDidDismiss() } + } } func body(content: Content) -> some View { diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift deleted file mode 100644 index ea9317513c..0000000000 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStubs.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// CustomerCenterStubs.swift -// -// -// Created by Claude on 20/08/2026. -// - -import SwiftUI - -// Stub — implemented in a later commit (Task 13: survey, history, account details, -// update banner, duplicate banner, restore overlay). These exist only so -// CustomerCenterView and friends build and the Task 12 smoke test passes. - -@available(iOS 15.0, *) -struct RestoreOverlay: View { - @ObservedObject var viewModel: CustomerCenterViewModel - var body: some View { EmptyView() } -} - -@available(iOS 15.0, *) -struct AppUpdateWarningView: View { - @ObservedObject var viewModel: CustomerCenterViewModel - var body: some View { Section { EmptyView() } } -} - -@available(iOS 15.0, *) -struct DuplicateSubscriptionBanner: View { - var body: some View { Section { EmptyView() } } -} - -@available(iOS 15.0, *) -struct FeedbackSurveyView: View { - @ObservedObject var viewModel: CustomerCenterViewModel - var body: some View { EmptyView() } -} - -@available(iOS 15.0, *) -struct PurchaseHistoryView: View { - @ObservedObject var viewModel: CustomerCenterViewModel - var body: some View { EmptyView() } -} - -@available(iOS 15.0, *) -struct AccountDetailsSection: View { - @ObservedObject var viewModel: CustomerCenterViewModel - var body: some View { Section { EmptyView() } } -} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 10d8e4582d..387a474d19 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -124,9 +124,5 @@ public struct CustomerCenterView: View { CustomerCenterTheme(appearance: viewModel.configuration.appearance, colorScheme: colorScheme) } - private var themeAccent: Color? { - guard let pair = viewModel.configuration.appearance.accent else { return nil } - let hex = colorScheme == .dark ? pair.dark : pair.light - return UIColor(hex: hex).map(Color.init) - } + 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..46d8957571 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift @@ -0,0 +1,24 @@ +// +// DuplicateSubscriptionBanner.swift +// +// +// Created by Claude 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..e9a8bd2aee --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift @@ -0,0 +1,56 @@ +// +// FeedbackSurveyView.swift +// +// +// Created by Claude 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/PurchaseCardView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift index 8e030c6e94..97b2ff1fee 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift @@ -18,7 +18,7 @@ struct PurchaseCardView: View { HStack { Text(purchase.title).font(.headline) Spacer() - BadgeView(badge: purchase.badge) + BadgeView(badge: purchase.badge, rowId: purchase.productId ?? purchase.id) } if let price = purchase.priceLine { Text(price).font(.subheadline) } Text(purchase.statusLine).font(.subheadline).foregroundStyle(.secondary) @@ -41,6 +41,7 @@ struct PurchaseCardView: View { @available(iOS 15.0, *) struct BadgeView: View { let badge: PurchaseBadge + var rowId: String? @Environment(\.customerCenterStrings) private var strings private var key: String { @@ -70,6 +71,6 @@ struct BadgeView: View { .background(color.opacity(0.15)) .foregroundStyle(color) .clipShape(Capsule()) - .accessibilityIdentifier("customer_center.badge.\(key)") + .accessibilityIdentifier(rowId.map { "customer_center.badge.\(key).\($0)" } ?? "customer_center.badge.\(key)") } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift new file mode 100644 index 0000000000..3a00d7fa00 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -0,0 +1,86 @@ +// +// PurchaseHistoryView.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI + +@available(iOS 15.0, *) +struct PurchaseHistoryView: View { + @ObservedObject var viewModel: CustomerCenterViewModel + @Environment(\.customerCenterStrings) private var strings + + var body: some View { + let sections = viewModel.historySections() + List { + historySection("customer_center_history_active", sections.active) + historySection("customer_center_history_expired", sections.expired) + historySection("customer_center_history_other", sections.other) + } + .listStyle(.insetGrouped) + .navigationTitle(strings.string("customer_center_purchase_history")) + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func historySection(_ key: String, _ items: [PurchasePresentation]) -> some View { + if !items.isEmpty { + Section(strings.string(key)) { + ForEach(items) { item in + NavigationLink { + PurchaseDetailRows(purchase: item) + } label: { + PurchaseCardView(purchase: item, refundResult: nil) + } + } + } + } + } +} + +@available(iOS 15.0, *) +struct PurchaseDetailRows: View { + let purchase: PurchasePresentation + @Environment(\.customerCenterStrings) private var strings + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + var body: some View { + List { + Section { + row(strings.string("customer_center_product_id"), purchase.productId ?? "—") + if let date = purchase.purchaseDate { + row(strings.string("customer_center_purchase_date"), dateFormatter.string(from: date)) + } + if let date = purchase.expirationDate { + row(strings.string("customer_center_expiration_date"), dateFormatter.string(from: date)) + } + row(strings.string("customer_center_store"), purchase.storeLabelKey.map { strings.string($0) } ?? "App Store") + if let sub = purchase.subscription { + row(strings.string("customer_center_transaction_id"), sub.transactionId) + if let offer = sub.offerType { row("Offer", offer.rawValue) } + } + if case .nonSubscription(let transaction) = purchase.kind { + row(strings.string("customer_center_transaction_id"), transaction.transactionId) + } + } + #if DEBUG + Section("Debug") { + row(strings.string("customer_center_sandbox"), String(ReceiptManager.isSandboxEnvironment ?? false)) + } + #endif + } + .navigationTitle(purchase.title) + .navigationBarTitleDisplayMode(.inline) + } + + private func row(_ label: String, _ value: String) -> some View { + HStack { Text(label); Spacer(); Text(value).foregroundStyle(.secondary).textSelection(.enabled) } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift new file mode 100644 index 0000000000..ab2d48a709 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift @@ -0,0 +1,64 @@ +// +// RestoreOverlay.swift +// +// +// Created by Claude 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 9ee8ae3aae..5cd457c7dc 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ 309EC3675C7EF75050B076E7 /* ComputedPropertyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.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 */; }; @@ -137,7 +138,6 @@ 3824D48F8E0AF35EEBED8FF4 /* PaywallViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D96B4C9B546F7B0EC73397 /* PaywallViewControllerWrapper.swift */; }; 3860BFEF3E9A4F76A90480F1 /* TestModeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF301A818CE83F4EBB3A7003 /* TestModeManager.swift */; }; 38A74D299EDFCA3F0A5261B7 /* TransactionErrorLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76A719AD2F4475F83DFC9575 /* TransactionErrorLogic.swift */; }; - 397D038B9C29E1101048D4BA /* CustomerCenterStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */; }; 3A4A22150A6EB1C234BAC722 /* TestModeRestoreDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A0325FB47A06456B909BCB5 /* TestModeRestoreDrawer.swift */; }; 3B6322A7C3392F4729E00B70 /* SK1StoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99DE58E263F7AE44DDB6BD52 /* SK1StoreProductDiscount.swift */; }; 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03471273DF4C875227102BE2 /* ReceiptManagerTests.swift */; }; @@ -326,6 +326,7 @@ 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 */; }; @@ -357,6 +358,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 */; }; @@ -386,6 +388,7 @@ 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 */; }; @@ -536,6 +539,7 @@ E0F3648081AB86077201EB5D /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83416F0F1B5294C350D5CF70 /* FeatureFlags.swift */; }; E0F69E406F64A1160FF55BFA /* SWProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460B6F98BADD9EC96A978E40 /* SWProduct.swift */; }; E1A838C9CE62C9479D0C68F4 /* SWDebugManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */; }; + E23BC32639E66B3992FB6959 /* PurchaseHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E705F9954F808C341A4D0EBD /* PurchaseHistoryView.swift */; }; E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59A767F107FB1FBBC2F22DB3 /* AppSessionManagerTests.swift */; }; E315F3C6BBCA8582BF540086 /* GetExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */; }; E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81D80A7C5B8A17B83C218656 /* MapSwiftErrors.swift */; }; @@ -569,6 +573,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 */; }; @@ -697,6 +702,7 @@ 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 = ""; }; 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomProductTests.swift; sourceTree = ""; }; 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerInfo.swift; sourceTree = ""; }; @@ -789,9 +795,9 @@ 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 = ""; }; - 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStubs.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 = ""; }; @@ -880,6 +886,7 @@ 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 = ""; }; 6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; }; 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExperiment.swift; sourceTree = ""; }; @@ -1003,11 +1010,13 @@ 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 = ""; }; @@ -1214,6 +1223,7 @@ E4623C4E5EDA10B38746C384 /* LocationPermissionDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegateTests.swift; sourceTree = ""; }; E4DC3F3B888F2DC4CC4747CB /* CacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheTests.swift; sourceTree = ""; }; E51D0B38180377D9CD3E65DA /* Date+TimeIntervalMilliseconds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeIntervalMilliseconds.swift"; sourceTree = ""; }; + E705F9954F808C341A4D0EBD /* PurchaseHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseHistoryView.swift; sourceTree = ""; }; E72593E1D4123B176EC83499 /* WebArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebArchive.swift; sourceTree = ""; }; E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataManagerFakeDataMock.swift; sourceTree = ""; }; E7F1150DA75C81CB3815F2F4 /* ConfigurationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationStatus.swift; sourceTree = ""; }; @@ -1423,15 +1433,20 @@ 1422D4F63A53E2768C2E90E6 /* Views */ = { isa = PBXGroup; children = ( + 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */, + 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */, 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */, 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */, 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */, - 4685E590BA6D1013C0A7D00B /* CustomerCenterStubs.swift */, 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */, + 47D400B1629D13D5BF38370B /* DuplicateSubscriptionBanner.swift */, + 9B079AAB038F2DE800E71AD8 /* FeedbackSurveyView.swift */, B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */, 10863EC29BADB2823086E14D /* NoActiveScreenView.swift */, 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */, A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */, + E705F9954F808C341A4D0EBD /* PurchaseHistoryView.swift */, + 9C515E4FDA405CBFADFA9FAB /* RestoreOverlay.swift */, ); path = Views; sourceTree = ""; @@ -3623,6 +3638,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 */, @@ -3632,6 +3648,7 @@ 995FD66283C7B03D3B33DF89 /* AppSessionLogic.swift in Sources */, E986B0CF98B8C09AAA961E94 /* AppSessionManager.swift in Sources */, 5DE5CE789559545FF1A8AD12 /* AppStoreProduct.swift in Sources */, + 32A52161B29C999F06217B9A /* AppUpdateWarningView.swift in Sources */, C71FC781059E1BE197CE9C38 /* AppVersionComparator.swift in Sources */, 5DDABDA8ECE4A96BDFCEF4B0 /* ArchivalManifestDownloaded.swift in Sources */, F61541FC6670E0667A96FE44 /* ArchiveManifest.swift in Sources */, @@ -3692,7 +3709,6 @@ 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */, 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, - 397D038B9C29E1101048D4BA /* CustomerCenterStubs.swift in Sources */, FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, @@ -3715,6 +3731,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 */, @@ -3743,6 +3760,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 */, @@ -3901,6 +3919,7 @@ 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */, AC0AF760E7EA2FFF5621955D /* PurchaseControllerObjcAdapter.swift in Sources */, EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */, + E23BC32639E66B3992FB6959 /* PurchaseHistoryView.swift in Sources */, 070DFAAB357CE1D547E946E1 /* PurchaseManager.swift in Sources */, C576C1F4D9DF866BEE44477C /* PurchasePresentation.swift in Sources */, 3C9432E5304D2C50E5B2B06F /* PurchasePresentationBuilder.swift in Sources */, @@ -3923,6 +3942,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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift index 2410d4873b..33268c01d9 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -50,4 +50,42 @@ struct CustomerCenterViewSmokeTests { 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) + for view in [AnyView(FeedbackSurveyView(viewModel: vm)), AnyView(PurchaseHistoryView(viewModel: vm))] { + 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 + } + } } From 1f070fc3eaac507497f465544e3b21ab1b2d714b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 18:00:54 -0500 Subject: [PATCH 14/42] feat(customer-center): add delegate protocols and UIKit view controller Co-Authored-By: Claude Fable 5 --- .../Delegate/CustomerCenterDelegate.swift | 51 +++++++++++ .../UIKit/CustomerCenterDelegateAdapter.swift | 64 ++++++++++++++ .../UIKit/CustomerCenterViewController.swift | 87 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 40 +++++++++ .../CustomerCenterDelegateAdapterTests.swift | 54 ++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift new file mode 100644 index 0000000000..e419d813e5 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift @@ -0,0 +1,51 @@ +// +// CustomerCenterDelegate.swift +// +// +// Created by Claude 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/UIKit/CustomerCenterDelegateAdapter.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift new file mode 100644 index 0000000000..52d8451d2e --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift @@ -0,0 +1,64 @@ +// +// CustomerCenterDelegateAdapter.swift +// +// +// Created by Claude 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/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift new file mode 100644 index 0000000000..4391353679 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -0,0 +1,87 @@ +// +// CustomerCenterViewController.swift +// +// +// Created by Claude on 20/08/2026. +// + +import SwiftUI +import UIKit + +/// A UIKit container for ``CustomerCenterView``. +@available(iOS 15.0, *) +@objc(SWKCustomerCenterViewController) +public final class CustomerCenterViewController: UIViewController { + let viewModel: CustomerCenterViewModel + private var hosting: UIHostingController? + var onDismiss: (() -> Void)? + + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter``; `nil` uses the options value. + /// - 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, + delegate: CustomerCenterDelegate? = nil + ) { + self.init( + viewModel: CustomerCenterManager.makeViewModel(configuration: configuration), + adapter: CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil) + ) + } + + /// Objective-C initializer. + /// - Parameters: + /// - configuration: Overrides ``SuperwallOptions/customerCenter``; `nil` uses the options value. + /// - 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. + @objc public convenience init(configuration: CustomerCenterConfiguration?, objcDelegate: CustomerCenterDelegateObjc?) { + self.init( + viewModel: CustomerCenterManager.makeViewModel(configuration: configuration), + adapter: CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: objcDelegate) + ) + } + + init(viewModel: CustomerCenterViewModel, adapter: CustomerCenterDelegateAdapter) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + viewModel.callbacks = adapter.makeCallbacks() + modalPresentationStyle = .pageSheet + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override public func viewDidLoad() { + super.viewDidLoad() + let options = CustomerCenterNavigationOptions( + usesExistingNavigation: false, + showsCloseButton: true + ) { [weak self] in + self?.dismiss(animated: true) + } + let host = UIHostingController(rootView: CustomerCenterView(viewModel: viewModel, navigationOptions: options)) + addChild(host) + view.addSubview(host.view) + host.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + host.didMove(toParent: self) + hosting = host + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || presentingViewController == nil { + onDismiss?() + } + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 5cd457c7dc..354a44f9c6 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 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 */; }; @@ -169,6 +170,7 @@ 454421E34ED200400A001AE1 /* PaywallManagerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.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 */; }; 4A3DD598AC298C6A2A371622 /* CustomerCenterActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D1099BCB8303DDD6415D9B7 /* CustomerCenterActionTests.swift */; }; @@ -279,6 +281,7 @@ 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 */; }; @@ -309,6 +312,7 @@ 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 */; }; @@ -704,6 +708,7 @@ 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 = ""; }; 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 = ""; }; @@ -862,6 +867,7 @@ 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 = ""; }; 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsManager.swift; sourceTree = ""; }; @@ -934,6 +940,7 @@ 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 = ""; }; 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 = ""; }; @@ -1046,6 +1053,7 @@ 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 = ""; }; @@ -1299,6 +1307,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 035954DCF2214A634651A352 /* UIKit */ = { + isa = PBXGroup; + children = ( + 632BA7AFDDD93F08252C9043 /* CustomerCenterDelegateAdapter.swift */, + 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */, + ); + path = UIKit; + sourceTree = ""; + }; 068A223BBB54F9127201504F /* Paywall */ = { isa = PBXGroup; children = ( @@ -1877,6 +1894,7 @@ 9723663065538DB5CF16F4A4 /* Actions */, 4664D61C9B4C8ADC2B834E36 /* Logic */, E40538D195AAE4E177C98959 /* Models */, + FCAA9EC0D71CB6AE1EBEB75A /* UIKit */, B5DA90160501C06A71BE97C5 /* ViewModel */, 0885E36F54C6369D2E5FCDC7 /* Views */, ); @@ -2584,6 +2602,14 @@ path = Logic; sourceTree = ""; }; + 942120C628E5C7B6E603287A /* Delegate */ = { + isa = PBXGroup; + children = ( + 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */, + ); + path = Delegate; + sourceTree = ""; + }; 961CAF7F687CC7D8CEDB40F3 /* Notifications */ = { isa = PBXGroup; children = ( @@ -3164,8 +3190,10 @@ children = ( 165B7C00C437146FB5C9DB92 /* CustomerCenterManager.swift */, 4AC7FD1A50349966FF78DB51 /* Actions */, + 942120C628E5C7B6E603287A /* Delegate */, 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, + 035954DCF2214A634651A352 /* UIKit */, 6CA03A908C710F4F27075427 /* ViewModel */, 1422D4F63A53E2768C2E90E6 /* Views */, ); @@ -3317,6 +3345,14 @@ path = "Receipt Models"; sourceTree = ""; }; + FCAA9EC0D71CB6AE1EBEB75A /* UIKit */ = { + isa = PBXGroup; + children = ( + A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */, + ); + path = UIKit; + sourceTree = ""; + }; FD8F67EFF69ECDBE85EB24F5 /* Message Handling */ = { isa = PBXGroup; children = ( @@ -3506,6 +3542,7 @@ 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 */, @@ -3702,6 +3739,8 @@ B03C4840E7E3DEAE814B374E /* CustomerCenterAction.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 */, @@ -3710,6 +3749,7 @@ 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, + 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */, 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift new file mode 100644 index 0000000000..eff3372214 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift @@ -0,0 +1,54 @@ +// +// CustomerCenterDelegateAdapterTests.swift +// +// +// Created by Claude 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) + } +} From 91f3e89e91623ef1459ef9b0c2cbeda30efebc8b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 19:38:09 -0500 Subject: [PATCH 15/42] feat(customer-center): add CustomerCenterManager and Superwall.presentCustomerCenter Co-Authored-By: Claude Fable 5 --- .../CustomerCenterManager.swift | 140 +++++++++++++++++- .../Dependencies/DependencyContainer.swift | 20 +++ .../Superwall+CustomerCenter.swift | 91 ++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 8 + .../CustomerCenterManagerTests.swift | 114 ++++++++++++++ 5 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 Sources/SuperwallKit/Superwall+CustomerCenter.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift index 6c640cdcdf..0ac5941816 100644 --- a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -5,23 +5,147 @@ // Created by Claude on 20/08/2026. // -import Foundation +import UIKit -/// Builds the dependencies and view model backing ``CustomerCenterView``. -/// -/// This file currently holds just the static factory `CustomerCenterView` needs. It is expanded -/// with the full public presentation API in a later commit. +/// Builds the dependencies backing ``CustomerCenterView`` and owns the single Customer Center +/// presentation for ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)``. @available(iOS 15.0, *) @MainActor -enum CustomerCenterManager { +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 { let container = Superwall.shared.dependencyContainer let resolved = configuration ?? container.configManager.options.customerCenter - let dependencies = CustomerCenterDependencies.live(container: container, configuration: resolved) return CustomerCenterViewModel( configuration: resolved, - dependencies: dependencies, + 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) + 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/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba96227197..c7145918be 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -48,6 +48,17 @@ 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. + @available(iOS 15.0, *) + var customerCenterManager: CustomerCenterManager { + // swiftlint:disable:next force_cast + _customerCenterManager as! CustomerCenterManager + } + init( apiKey: String = "", purchaseController controller: PurchaseController? = nil, @@ -210,6 +221,15 @@ final class DependencyContainer { productsManager: productsManager, factory: self ) + + if #available(iOS 15.0, *) { + // `DependencyContainer.init` runs on the main thread at configure time, but the initializer + // itself isn't statically main-actor-isolated, so we assert isolation to construct the + // main-actor-isolated `CustomerCenterManager`. + MainActor.assumeIsolated { + _customerCenterManager = CustomerCenterManager(container: self) + } + } } } diff --git a/Sources/SuperwallKit/Superwall+CustomerCenter.swift b/Sources/SuperwallKit/Superwall+CustomerCenter.swift new file mode 100644 index 0000000000..4c1de6d52b --- /dev/null +++ b/Sources/SuperwallKit/Superwall+CustomerCenter.swift @@ -0,0 +1,91 @@ +// +// Superwall+CustomerCenter.swift +// +// +// Created by Claude 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(iOS 15.0, *) + @MainActor + 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, *) + @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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 354a44f9c6..11f5b92d14 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ 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 */; }; @@ -97,6 +98,7 @@ 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 */; }; @@ -863,6 +865,7 @@ 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 = ""; }; @@ -1219,6 +1222,7 @@ 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 = ""; }; 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 = ""; }; @@ -1890,6 +1894,7 @@ isa = PBXGroup; children = ( 81F69ACFBD6522C150971839 /* CustomerCenterEventsTests.swift */, + E19BDEFD1BAB331A814E95CE /* CustomerCenterManagerTests.swift */, 58EA2C2A9FFC16FA7B90A31B /* CustomerCenterStringsTests.swift */, 9723663065538DB5CF16F4A4 /* Actions */, 4664D61C9B4C8ADC2B834E36 /* Logic */, @@ -2453,6 +2458,7 @@ 0EA0BD57CE7F03A50ACA9D25 /* DeepLinkRouter.swift */, C22CA9431D5F791BE7A9BE27 /* Documentation.docc */, 2F7EDB6D68D0AEDD332E40BB /* Superwall.swift */, + 618F0D37FD3B2213A296DEF4 /* Superwall+CustomerCenter.swift */, 33C89C06ECF942287FA14087 /* Analytics */, 91B8F43244A7C30402275032 /* Config */, E4455CBE23BD58AF980439B4 /* CustomerCenter */, @@ -3546,6 +3552,7 @@ 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 */, BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, DC3ECD6BD248CCA5322CE05E /* CustomerCenterViewModelTests.swift in Sources */, @@ -4034,6 +4041,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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift new file mode 100644 index 0000000000..e1bf821d45 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift @@ -0,0 +1,114 @@ +// +// CustomerCenterManagerTests.swift +// +// +// Created by Claude on 20/08/2026. +// + +import Testing +import Foundation +import UIKit +@testable import SuperwallKit + +@Suite("CustomerCenterManager") +@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 + } + + /// 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)) + } + } +} From 412a10f66ef7c2abfab046ef25ce05cbc9a6e401 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 19:55:39 -0500 Subject: [PATCH 16/42] fix(customer-center): lazily create CustomerCenterManager on the main actor DependencyContainer.init constructed CustomerCenterManager via MainActor.assumeIsolated at the end of init, but init itself isn't @MainActor. ~20 test suites (and any host app calling Superwall.configure off-main) construct DependencyContainer off the main thread, crashing with EXC_BREAKPOINT. Fixed by deferring construction to the customerCenterManager accessor itself, now marked @MainActor and built lazily on first access; all production call sites (Superwall.presentCustomerCenter/dismissCustomerCenter/ the Objective-C variant) are already @MainActor, so this needs no assumeIsolated. Also logs a loud warning from CustomerCenterManager.makeViewModel(configuration:) when Superwall hasn't been configured yet, since CustomerCenterView/ CustomerCenterViewController route through it and would otherwise silently render a dead screen with no purchase data. --- .../CustomerCenterManager.swift | 8 +++++++ .../Dependencies/DependencyContainer.swift | 24 ++++++++++--------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift index 0ac5941816..e4040efc11 100644 --- a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -54,6 +54,14 @@ final class CustomerCenterManager { /// 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( diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index c7145918be..e168b42c2a 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -53,10 +53,21 @@ final class DependencyContainer { 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 { - // swiftlint:disable:next force_cast - _customerCenterManager as! CustomerCenterManager + if let manager = _customerCenterManager as? CustomerCenterManager { + return manager + } + let manager = CustomerCenterManager(container: self) + _customerCenterManager = manager + return manager } init( @@ -221,15 +232,6 @@ final class DependencyContainer { productsManager: productsManager, factory: self ) - - if #available(iOS 15.0, *) { - // `DependencyContainer.init` runs on the main thread at configure time, but the initializer - // itself isn't statically main-actor-isolated, so we assert isolation to construct the - // main-actor-isolated `CustomerCenterManager`. - MainActor.assumeIsolated { - _customerCenterManager = CustomerCenterManager(container: self) - } - } } } From 2dc9837d610bf730339de5c37933b2df6bfc9d43 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 20:14:08 -0500 Subject: [PATCH 17/42] feat(customer-center): add SwiftUI presentation and callback modifiers Co-Authored-By: Claude Fable 5 --- .../SwiftUI/View+CustomerCenter.swift | 91 +++++++++++++++ .../Views/CustomerCenterView.swift | 23 +++- SuperwallKit.xcodeproj/project.pbxproj | 12 ++ .../Views/CustomerCenterViewSmokeTests.swift | 109 ++++++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift diff --git a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift new file mode 100644 index 0000000000..ec9647a7b6 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift @@ -0,0 +1,91 @@ +// +// View+CustomerCenter.swift +// +// +// Created by Claude 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. + /// - 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 presentCustomerCenter( + 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/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 387a474d19..950343d55c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -43,6 +43,7 @@ public struct CustomerCenterView: View { private let navigationOptions: CustomerCenterNavigationOptions @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme + @Environment(\.customerCenterCallbacks) private var callbacksBox /// Creates a Customer Center view. /// - Parameters: @@ -73,10 +74,30 @@ public struct CustomerCenterView: View { } .environment(\.customerCenterStrings, viewModel.strings) .environment(\.customerCenterTheme, theme) - .task { await viewModel.load() } + .task { + viewModel.callbacks = Self.merged(viewModel.callbacks, callbacksBox.callbacks) + await viewModel.load() + } .onDisappear { viewModel.dismiss() } } + /// 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) diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 11f5b92d14..6faa231b25 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -569,6 +569,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 */; }; @@ -1101,6 +1102,7 @@ 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 = ""; }; @@ -2399,6 +2401,14 @@ path = Internal; sourceTree = ""; }; + 797427D95711FB08D8A372E2 /* SwiftUI */ = { + isa = PBXGroup; + children = ( + B60CC773CBB97573AC2326D3 /* View+CustomerCenter.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 79E3E1B6A224AE8B4085CF9B /* Templating */ = { isa = PBXGroup; children = ( @@ -3199,6 +3209,7 @@ 942120C628E5C7B6E603287A /* Delegate */, 5E4DEFC8C051825F0007162E /* Logic */, AC076DCADFAF818A0325BA18 /* Models */, + 797427D95711FB08D8A372E2 /* SwiftUI */, 035954DCF2214A634651A352 /* UIKit */, 6CA03A908C710F4F27075427 /* ViewModel */, 1422D4F63A53E2768C2E90E6 /* Views */, @@ -4118,6 +4129,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/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift index 33268c01d9..e0b8593f94 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -88,4 +88,113 @@ struct CustomerCenterViewSmokeTests { window.isHidden = true } } + + @Test("presentCustomerCenter(isPresented:) compiles and hosts") + @available(iOS 15.0, *) + func presentCustomerCenterHosts() { + struct Host: View { + @State var isPresented = true + var body: some View { + NavigationView { Text("Root") }.presentCustomerCenter(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) + } } From 417e0a26f747a1e1387a87fa2a2690fab174c57f Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 20:24:17 -0500 Subject: [PATCH 18/42] feat(customer-center): examples, docs, changelog Adds a Customer Center button to the Basic and Advanced example apps, a CustomerCenter.md DocC article, and CHANGELOG entries under the already-staged 4.16.4 release (develop is ahead of master, so no version bump is needed). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + .../Advanced.xcodeproj/project.pbxproj | 4 + .../CustomerCenterExampleDelegate.swift | 36 ++++ Examples/Advanced/Advanced/HomeView.swift | 28 +++ Examples/Basic/Basic/HomeView.swift | 3 + .../Documentation.docc/CustomerCenter.md | 169 ++++++++++++++++++ .../Documentation.docc/SuperwallKit.md | 6 + 7 files changed, 251 insertions(+) create mode 100644 Examples/Advanced/Advanced/CustomerCenterExampleDelegate.swift create mode 100644 Sources/SuperwallKit/Documentation.docc/CustomerCenter.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179377bd1..cd00db5a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 4.16.4 +### 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). Requires iOS 15+. +- Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. + ### Fixes - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. 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..d515cad34e 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")!, openMethod: .inApp)), + .init(id: "contact_support", type: .contactSupport) + ] + ), + noActiveScreen: .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/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md new file mode 100644 index 0000000000..741b56087b --- /dev/null +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -0,0 +1,169 @@ +# 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 browse purchase history — 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 appearance 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 embed it directly using ``CustomerCenterViewController``: + +```swift +let customerCenter = CustomerCenterViewController(delegate: myDelegate) +present(customerCenter, animated: true) +``` + +### Presenting from SwiftUI + +Use the ``SwiftUICore/View/presentCustomerCenter(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 + } + .presentCustomerCenter(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:)-52tke`, 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")!, openMethod: .inApp)), + .init(id: "contact_support", type: .contactSupport) + ] + ), + noActiveScreen: .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(_:openMethod:)`` opens a +URL either in-app or externally, and ``CustomerCenterConfiguration/PathType/custom(identifier:)`` +lets you handle an action entirely yourself via the delegate. + +## 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. +- 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` From 52e4f98fab72ab162f493eb282b4cb35685a27b3 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 21:57:26 -0500 Subject: [PATCH 19/42] =?UTF-8?q?fix(customer-center):=20final=20review=20?= =?UTF-8?q?fixes=20=E2=80=94=20dedupe=20renewals,=20sheet=20handoff,=20emb?= =?UTF-8?q?edded=20dismiss,=20receipt=20refresh,=20ObjC=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../TrackableSuperwallEvent.swift | 6 +- .../Config/Models/TestStoreUser.swift | 2 +- .../CustomerCenterManager.swift | 2 +- .../Delegate/CustomerCenterDelegate.swift | 2 +- .../Logic/AppVersionComparator.swift | 8 +- .../Logic/CustomerCenterPathResolver.swift | 8 +- .../Logic/PurchasePresentationBuilder.swift | 32 +++- .../Logic/SupportEmailComposer.swift | 2 +- .../Models/CustomerCenterConfiguration.swift | 48 +++++- .../Models/CustomerCenterScreenState.swift | 2 - .../SwiftUI/View+CustomerCenter.swift | 2 +- .../UIKit/CustomerCenterDelegateAdapter.swift | 2 +- .../UIKit/CustomerCenterViewController.swift | 6 +- .../CustomerCenterDependencies.swift | 8 + .../ViewModel/CustomerCenterViewModel.swift | 137 +++++++++++++----- .../Views/AccountDetailsSection.swift | 2 +- .../Views/AppUpdateWarningView.swift | 2 +- .../Views/CustomerCenterEnvironment.swift | 2 +- .../Views/CustomerCenterSheets.swift | 6 +- .../Views/CustomerCenterStrings+English.swift | 1 + .../Views/CustomerCenterView.swift | 28 +++- .../Views/DuplicateSubscriptionBanner.swift | 2 +- .../Views/FeedbackSurveyView.swift | 2 +- .../Views/ManagementScreenView.swift | 7 +- .../Views/NoActiveScreenView.swift | 2 +- .../CustomerCenter/Views/PathsListView.swift | 6 +- .../Views/PurchaseCardView.swift | 2 +- .../Views/PurchaseHistoryView.swift | 6 +- .../CustomerCenter/Views/RestoreOverlay.swift | 2 +- .../Documentation.docc/CustomerCenter.md | 2 +- .../Network/V2ProductsResponse.swift | 2 +- .../ar.lproj/Localizable.strings | 1 + .../ca.lproj/Localizable.strings | 1 + .../cs.lproj/Localizable.strings | 1 + .../da.lproj/Localizable.strings | 1 + .../de.lproj/Localizable.strings | 1 + .../el.lproj/Localizable.strings | 1 + .../en.lproj/Localizable.strings | 1 + .../en_AU.lproj/Localizable.strings | 1 + .../en_GB.lproj/Localizable.strings | 1 + .../es.lproj/Localizable.strings | 1 + .../es_419.lproj/Localizable.strings | 1 + .../fi.lproj/Localizable.strings | 1 + .../fr.lproj/Localizable.strings | 1 + .../fr_CA.lproj/Localizable.strings | 1 + .../he.lproj/Localizable.strings | 1 + .../hi.lproj/Localizable.strings | 1 + .../hr.lproj/Localizable.strings | 1 + .../hu.lproj/Localizable.strings | 1 + .../id.lproj/Localizable.strings | 1 + .../it.lproj/Localizable.strings | 1 + .../ja.lproj/Localizable.strings | 1 + .../ko.lproj/Localizable.strings | 1 + .../ms.lproj/Localizable.strings | 1 + .../nb.lproj/Localizable.strings | 1 + .../nl.lproj/Localizable.strings | 1 + .../nn.lproj/Localizable.strings | 1 + .../pl.lproj/Localizable.strings | 1 + .../pt.lproj/Localizable.strings | 1 + .../pt_BR.lproj/Localizable.strings | 1 + .../pt_PT.lproj/Localizable.strings | 1 + .../ro.lproj/Localizable.strings | 1 + .../ru.lproj/Localizable.strings | 1 + .../sk.lproj/Localizable.strings | 1 + .../sl.lproj/Localizable.strings | 1 + .../sv.lproj/Localizable.strings | 1 + .../th.lproj/Localizable.strings | 1 + .../tr.lproj/Localizable.strings | 1 + .../uk.lproj/Localizable.strings | 1 + .../vi.lproj/Localizable.strings | 1 + .../zh_Hans.lproj/Localizable.strings | 1 + .../zh_Hant.lproj/Localizable.strings | 1 + .../EntitlementProcessor.swift | 2 +- .../Superwall+CustomerCenter.swift | 6 +- ...stModeDeviceAttributesViewController.swift | 2 +- .../Alert/TestModeEntitlementRowView.swift | 2 +- .../TestMode/Alert/TestModeInfoCell.swift | 2 +- .../TestMode/Alert/TestModeModal.swift | 2 +- ...estModeModalViewController+TableView.swift | 2 +- .../Alert/TestModeModalViewController.swift | 2 +- .../TestMode/TestModeManager.swift | 2 +- .../TestMode/TestModeManagerFactory.swift | 2 +- .../TestMode/TestModePurchaseDrawer.swift | 2 +- .../TestMode/TestModeRestoreDrawer.swift | 2 +- .../TestMode/TestModeTransactionHandler.swift | 2 +- .../Attribution/AttributionTests.swift | 2 +- .../CustomerCenterEventsTests.swift | 13 ++ .../CustomerCenterManagerTests.swift | 2 +- .../Logic/AppVersionComparatorTests.swift | 7 + .../CustomerCenterPathResolverTests.swift | 22 ++- .../PurchasePresentationBuilderTests.swift | 58 +++++++- .../Logic/SupportEmailComposerTests.swift | 2 +- .../CustomerCenterDelegateAdapterTests.swift | 2 +- .../CustomerCenterDependenciesMocks.swift | 4 + .../CustomerCenterViewModelTests.swift | 120 ++++++++++++++- .../Views/CustomerCenterViewSmokeTests.swift | 2 +- ...InternallySetSubscriptionStatusTests.swift | 2 +- .../Models/PaywallPresentationInfoTests.swift | 2 +- .../Presentation/PresentationIdTests.swift | 2 +- .../Request/StripeTrialEligibilityTests.swift | 2 +- .../PaywallViewControllerDrawerTests.swift | 2 +- .../PageViewMessageTests.swift | 2 +- .../Products/ProductsFetcherSK2Tests.swift | 2 +- .../EntitlementProcessorTests.swift | 2 +- .../SubscriptionPeriodPriceTests.swift | 2 +- 105 files changed, 552 insertions(+), 112 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index d7210ec91a..3ec2b3bdce 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -1212,9 +1212,13 @@ 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] } + func getSuperwallParameters() async -> [String: Any] { + ["screen": screen, "presentation": presentation] + } } struct CustomerCenterClose: TrackableSuperwallEvent { diff --git a/Sources/SuperwallKit/Config/Models/TestStoreUser.swift b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift index 74f6338c3a..dc4e2aebaf 100644 --- a/Sources/SuperwallKit/Config/Models/TestStoreUser.swift +++ b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift @@ -2,7 +2,7 @@ // TestStoreUser.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift index e4040efc11..8875c77bc4 100644 --- a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -2,7 +2,7 @@ // CustomerCenterManager.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import UIKit diff --git a/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift index e419d813e5..e09b950d48 100644 --- a/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift +++ b/Sources/SuperwallKit/CustomerCenter/Delegate/CustomerCenterDelegate.swift @@ -2,7 +2,7 @@ // CustomerCenterDelegate.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Foundation diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift index 6fa97e1a9d..a267f43ca8 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift @@ -1,4 +1,10 @@ -// Sources/SuperwallKit/CustomerCenter/Logic/AppVersionComparator.swift +// +// AppVersionComparator.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + import Foundation /// Compares marketing version strings on up to three leading numeric components. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift index 1896d1403f..e583e02ef3 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -15,6 +15,9 @@ struct PathResolutionContext { 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() } @@ -56,7 +59,10 @@ enum CustomerCenterPathResolver { switch path.type { case .restore: - return purchase == nil ? .restore : nil + // 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 diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift index e09c559380..8a2b7bd1bc 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -34,7 +34,20 @@ struct PurchasePresentationBuilder { _ subscriptions: [SubscriptionTransaction], products: [String: ProductDisplayInfo] ) -> [PurchasePresentation] { - let sorted = subscriptions.sorted { lhs, rhs in + // `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 @@ -46,14 +59,29 @@ struct PurchasePresentationBuilder { return sorted.map { presentation(for: $0, product: products[$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] ) -> [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.productId, + id: purchase.transactionId, kind: .nonSubscription(purchase), productId: purchase.productId, title: product?.title ?? purchase.productId, diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift index ebd2d4dc5f..ec2c3aae2e 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/SupportEmailComposer.swift @@ -2,7 +2,7 @@ // SupportEmailComposer.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Foundation diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index c5ba9305f5..71b29c1e78 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -92,6 +92,18 @@ public final class CustomerCenterConfiguration: NSObject, Codable { && warnsAboutDuplicateSubscriptions == other.warnsAboutDuplicateSubscriptions } + override public var hash: Int { + var hasher = Hasher() + hasher.combine(managementScreen) + hasher.combine(noActiveScreen) + hasher.combine(support) + hasher.combine(appearance) + hasher.combine(showsPurchaseHistory) + 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. @@ -115,6 +127,14 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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 @@ -143,10 +163,19 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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, Equatable { + public enum PathType: Codable, Hashable { case restore case manageSubscription /// `window`: optional seconds since purchase during which a refund may be requested. @@ -159,7 +188,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { } /// How a URL path opens. - public enum OpenMethod: String, Codable { + public enum OpenMethod: String, Codable, Hashable { case inApp case external } @@ -186,6 +215,14 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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 { @@ -202,6 +239,13 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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() + } } } diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift index ee15055798..c04d35f488 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift @@ -16,7 +16,6 @@ enum CustomerCenterSheet: Identifiable, Equatable { case changePlan(groupId: String?, productIds: [String]?) case refund(transactionId: UInt64, productId: String) case safari(URL) - case purchaseHistory case noMailApp(email: String) var id: String { @@ -27,7 +26,6 @@ enum CustomerCenterSheet: Identifiable, Equatable { return "change:\(groupId ?? ""):\(productIds?.joined(separator: ",") ?? "")" case .refund(let transactionId, _): return "refund:\(transactionId)" case .safari(let url): return "safari:\(url.absoluteString)" - case .purchaseHistory: return "history" case .noMailApp: return "nomail" } } diff --git a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift index ec9647a7b6..cb2b2209a9 100644 --- a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift +++ b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift @@ -2,7 +2,7 @@ // View+CustomerCenter.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift index 52d8451d2e..783c2bf05b 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift @@ -2,7 +2,7 @@ // CustomerCenterDelegateAdapter.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Foundation diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 4391353679..84dbab2d70 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -2,7 +2,7 @@ // CustomerCenterViewController.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI @@ -39,7 +39,9 @@ public final class CustomerCenterViewController: UIViewController { /// 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. - @objc public convenience init(configuration: CustomerCenterConfiguration?, objcDelegate: CustomerCenterDelegateObjc?) { + @available(swift, obsoleted: 1.0) + @objc(initWithConfiguration:delegate:) + public convenience init(configuration: CustomerCenterConfiguration?, objcDelegate: CustomerCenterDelegateObjc?) { self.init( viewModel: CustomerCenterManager.makeViewModel(configuration: configuration), adapter: CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: objcDelegate) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index ad2a051fd8..7438f7cd5b 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -11,6 +11,10 @@ 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 { @@ -98,6 +102,10 @@ extension ProductDisplayInfo { @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, *) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 33154015cf..94d7461da0 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -17,8 +17,9 @@ final class CustomerCenterViewModel: ObservableObject { @Published private(set) var state: CustomerCenterScreenState = .loading @Published private(set) var purchases: [PurchasePresentation] = [] - @Published var selectedPurchaseId: String? - @Published var sheet: CustomerCenterSheet? + @Published var sheet: CustomerCenterSheet? { + didSet { if let sheet { lastPresentedSheet = sheet } } + } @Published var restoreState: CustomerCenterRestoreState = .idle @Published private(set) var refundResult: (productId: String, status: CustomerCenterRefundStatus)? @Published private(set) var showsUpdateBanner = false @@ -30,11 +31,23 @@ final class CustomerCenterViewModel: ObservableObject { var presentationMode = "sheet" private(set) var pendingSurvey: PendingSurvey? + /// `true` while a screen the Customer Center pushed itself (purchase detail, purchase + /// history) covers the root view. In embedded mode (`usesExistingNavigation`) such a push + /// removes the root view from the hierarchy, which must not count as a dismissal. + var isNavigatingWithinCustomerCenter = false + private let dependencies: CustomerCenterDependencies 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? private var updateWarningDismissed = false private var hasTrackedOpen = false private var didDismiss = false @@ -74,7 +87,10 @@ final class CustomerCenterViewModel: ObservableObject { if !hasTrackedOpen { hasTrackedOpen = true await dependencies.tracker.track( - InternalSuperwallEvent.CustomerCenterOpen(screen: state == .management ? "management" : "no_active") + InternalSuperwallEvent.CustomerCenterOpen( + screen: state == .management ? "management" : "no_active", + presentation: presentationMode + ) ) } } @@ -114,41 +130,16 @@ final class CustomerCenterViewModel: ObservableObject { // MARK: - Paths - var selectedPurchase: PurchasePresentation? { purchases.first { $0.id == selectedPurchaseId } } - var userId: String { dependencies.environment.userId } var originalDownloadDate: Date? { dependencies.environment.originalDownloadDate } var appStoreURL: URL? { dependencies.environment.appStoreURL } - 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 - let active = purchases.filter(\.isActive).compactMap(\.productId) - return .init( - userId: env.userId, - appVersion: env.appVersion, - osVersion: env.osVersion, - deviceModel: env.deviceModel, - sdkVersion: env.sdkVersion, - activeEntitlementIds: active, - isSandbox: env.isSandbox - ) - } - - private var supportEmailAvailable: Bool { - guard let url = supportMailtoURL else { return false } - return dependencies.urlOpener.canOpen(url) || dependencies.environment.isSimulator - } - - func paths(for purchase: PurchasePresentation?) -> [ResolvedPath] { + /// 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-active), 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 == .noActive ? configuration.noActiveScreen : configuration.managementScreen let context = PathResolutionContext( purchase: purchase, @@ -157,7 +148,8 @@ final class CustomerCenterViewModel: ObservableObject { supportEmailAvailable: supportEmailAvailable, webManagementURL: dependencies.environment.webManagementURL, isChangePlanSheetAvailable: isChangePlanSheetAvailable, - canOpenURLs: dependencies.urlOpener.canOpenURLs && !dependencies.environment.isAppExtension + canOpenURLs: dependencies.urlOpener.canOpenURLs && !dependencies.environment.isAppExtension, + isScreenLevel: isScreenLevel ) return CustomerCenterPathResolver.resolve(screen.paths, context: context) } @@ -190,13 +182,17 @@ final class CustomerCenterViewModel: ObservableObject { )) 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 - await perform(pendingAction.resolved, purchase: pendingAction.purchase) } func cancelSurvey() { pendingSurvey = nil pendingAction = nil + pendingActionAfterSheetDismiss = nil if case .survey = sheet { sheet = nil } } @@ -218,6 +214,9 @@ final class CustomerCenterViewModel: ObservableObject { 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 { @@ -261,9 +260,27 @@ final class CustomerCenterViewModel: ObservableObject { sheet = nil } - /// Call when the manage-subscriptions or change-plan sheet closes; reloads to pick up changes. + /// 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 info = await dependencies.customerInfo.fetchCustomerInfo() + 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) } @@ -272,6 +289,14 @@ final class CustomerCenterViewModel: ObservableObject { showsUpdateBanner = false } + /// Call from the root view's `onDisappear`. In embedded mode a push within the Customer + /// Center (purchase detail / purchase history) also removes the root view from the + /// hierarchy, which must not count as a dismissal. + func rootViewDidDisappear() { + guard !isNavigatingWithinCustomerCenter else { return } + dismiss() + } + func dismiss() { guard !didDismiss else { return } didDismiss = true @@ -289,3 +314,39 @@ final class CustomerCenterViewModel: ObservableObject { return (subs.filter(\.isActive), subs.filter { !$0.isActive }, purchases.filter { $0.subscription == nil }) } } + +// 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 + let active = purchases.filter(\.isActive).compactMap(\.productId) + return .init( + userId: env.userId, + appVersion: env.appVersion, + osVersion: env.osVersion, + deviceModel: env.deviceModel, + sdkVersion: env.sdkVersion, + activeEntitlementIds: active, + 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/Views/AccountDetailsSection.swift b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift index 17843dfe9e..cbfcefb021 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/AccountDetailsSection.swift @@ -2,7 +2,7 @@ // AccountDetailsSection.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift index ae12561083..6a90f46ba7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift @@ -2,7 +2,7 @@ // AppUpdateWarningView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift index aa839142c7..b004f34986 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift @@ -2,7 +2,7 @@ // CustomerCenterEnvironment.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index f3cfce5ca1..842e7c570c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -2,7 +2,7 @@ // CustomerCenterSheets.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SafariServices @@ -37,7 +37,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { .init( get: { switch viewModel.sheet { - case .survey, .changePlan, .safari, .purchaseHistory, .noMailApp: return viewModel.sheet + case .survey, .changePlan, .safari, .noMailApp: return viewModel.sheet default: return nil } }, @@ -85,8 +85,6 @@ private struct CustomerCenterSheetsModifier: ViewModifier { ChangePlanSheet(groupId: groupId, productIds: productIds) case .safari(let url): SafariView(url: url).ignoresSafeArea() - case .purchaseHistory: - NavigationView { PurchaseHistoryView(viewModel: viewModel) } case .noMailApp(let email): Text(strings.string("customer_center_no_mail_app", email)).padding() default: diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 2374af41ff..cd05c24663 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -100,6 +100,7 @@ let englishStrings: [String: String] = [ "customer_center_product_id": "Product ID", "customer_center_store": "Store", "customer_center_sandbox": "Sandbox", + "customer_center_offer": "Offer", // Customer Center – restore "customer_center_restoring": "Restoring…", "customer_center_restore_success_title": "Purchases restored", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 950343d55c..cb253f9a4d 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -2,7 +2,7 @@ // CustomerCenterView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI @@ -53,12 +53,28 @@ public struct CustomerCenterView: View { configuration: CustomerCenterConfiguration? = nil, navigationOptions: CustomerCenterNavigationOptions = .default ) { - let model = CustomerCenterManager.makeViewModel(configuration: configuration) - model.presentationMode = navigationOptions.usesExistingNavigation ? "embedded" : "sheet" - _viewModel = StateObject(wrappedValue: model) + // `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 } + 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) { _viewModel = StateObject(wrappedValue: viewModel) self.navigationOptions = navigationOptions @@ -78,7 +94,9 @@ public struct CustomerCenterView: View { viewModel.callbacks = Self.merged(viewModel.callbacks, callbacksBox.callbacks) await viewModel.load() } - .onDisappear { viewModel.dismiss() } + // `rootViewDidDisappear` skips the dismissal when a screen the Customer Center pushed + // itself (detail / history) covers the root view in embedded mode. + .onDisappear { viewModel.rootViewDidDisappear() } } /// Combines the view model's existing callbacks (e.g. set by the UIKit adapter) with those diff --git a/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift b/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift index 46d8957571..51bb86f61e 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/DuplicateSubscriptionBanner.swift @@ -2,7 +2,7 @@ // DuplicateSubscriptionBanner.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift b/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift index e9a8bd2aee..bd41c6f564 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/FeedbackSurveyView.swift @@ -2,7 +2,7 @@ // FeedbackSurveyView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 8fc7503f8f..569ea2da02 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -2,7 +2,7 @@ // ManagementScreenView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI @@ -80,12 +80,13 @@ struct PurchaseDetailScreenView: View { List { Section { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) } Section(strings.string("customer_center_section_actions")) { - PathsListView(viewModel: viewModel, purchase: purchase) + PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) } } .listStyle(.insetGrouped) .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.selectedPurchaseId = purchase.id } + .onAppear { viewModel.isNavigatingWithinCustomerCenter = true } + .onDisappear { viewModel.isNavigatingWithinCustomerCenter = false } } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift index b2b9d7b201..0c8a6c0870 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift @@ -2,7 +2,7 @@ // NoActiveScreenView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift index 7b2355f656..446d6e7187 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -2,7 +2,7 @@ // PathsListView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI @@ -11,11 +11,13 @@ import SwiftUI 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)) { resolved in + ForEach(viewModel.paths(for: purchase, isScreenLevel: isScreenLevel)) { resolved in Button { guard loadingPathId == nil else { return } loadingPathId = resolved.id diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift index 97b2ff1fee..6e5e0d30df 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift @@ -2,7 +2,7 @@ // PurchaseCardView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index 3a00d7fa00..a64cc22cbb 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -2,7 +2,7 @@ // PurchaseHistoryView.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI @@ -22,6 +22,8 @@ struct PurchaseHistoryView: View { .listStyle(.insetGrouped) .navigationTitle(strings.string("customer_center_purchase_history")) .navigationBarTitleDisplayMode(.inline) + .onAppear { viewModel.isNavigatingWithinCustomerCenter = true } + .onDisappear { viewModel.isNavigatingWithinCustomerCenter = false } } @ViewBuilder @@ -64,7 +66,7 @@ struct PurchaseDetailRows: View { row(strings.string("customer_center_store"), purchase.storeLabelKey.map { strings.string($0) } ?? "App Store") if let sub = purchase.subscription { row(strings.string("customer_center_transaction_id"), sub.transactionId) - if let offer = sub.offerType { row("Offer", offer.rawValue) } + if let offer = sub.offerType { row(strings.string("customer_center_offer"), offer.rawValue) } } if case .nonSubscription(let transaction) = purchase.kind { row(strings.string("customer_center_transaction_id"), transaction.transactionId) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift index ab2d48a709..8c63e680c0 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/RestoreOverlay.swift @@ -2,7 +2,7 @@ // RestoreOverlay.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import SwiftUI diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 741b56087b..40f2f27ce6 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -63,7 +63,7 @@ Use `presentCustomerCenterWithConfiguration:from:delegate:onDismiss:`: ## Configuring the Customer Center Set the default configuration via ``SuperwallOptions/customerCenter`` before calling -`Superwall/configure(apiKey:purchaseController:options:completion:)-52tke`, or pass a +`Superwall.configure(apiKey:purchaseController:options:completion:)`, or pass a ``CustomerCenterConfiguration`` directly to a presentation call to override it for that presentation only. diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index 07b98f8011..4b24126bd1 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -2,7 +2,7 @@ // SuperwallProductsResponse.swift // Superwall // -// Created by Claude on 2026-01-26. +// Created by Jordan Morgan on 2026-01-26. // import Foundation diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 0cfc73b564..5ed5d2cf5b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "معرّف المنتج"; "customer_center_store" = "المتجر"; "customer_center_sandbox" = "بيئة اختبار (Sandbox)"; +"customer_center_offer" = "عرض"; /* Customer Center – restore */ "customer_center_restoring" = "جارٍ الاستعادة…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index e79d1ff73d..d43178c064 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID del producte"; "customer_center_store" = "Botiga"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "Restaurant…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 5d8e9ca912..6a53234fe4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produktu"; "customer_center_store" = "Obchod"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Nabídka"; /* Customer Center – restore */ "customer_center_restoring" = "Obnovování…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 543118f13d..8ac36bd1de 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Produkt-id"; "customer_center_store" = "Butik"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Tilbud"; /* Customer Center – restore */ "customer_center_restoring" = "Gendanner…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index cc2ef25d11..b84412b28a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Produkt-ID"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Angebot"; /* Customer Center – restore */ "customer_center_restoring" = "Wird wiederhergestellt…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 4e0fa83dc0..6932defe11 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Αναγνωριστικό προϊόντος"; "customer_center_store" = "Κατάστημα"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Προσφορά"; /* Customer Center – restore */ "customer_center_restoring" = "Γίνεται επαναφορά…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index ecfcd6f2fe..aa5fb38fc5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Product ID"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Offer"; /* Customer Center – restore */ "customer_center_restoring" = "Restoring…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index ecfcd6f2fe..aa5fb38fc5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Product ID"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Offer"; /* Customer Center – restore */ "customer_center_restoring" = "Restoring…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index ecfcd6f2fe..aa5fb38fc5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Product ID"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Offer"; /* Customer Center – restore */ "customer_center_restoring" = "Restoring…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index cbdc5e5265..be1b5be473 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID del producto"; "customer_center_store" = "Tienda"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "Restaurando…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 60069c7f78..87ed291ab9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID del producto"; "customer_center_store" = "Tienda"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "Restaurando…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index f5d833bbbe..4fcf9fb941 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Tuotetunnus"; "customer_center_store" = "Kauppa"; "customer_center_sandbox" = "Hiekkalaatikko"; +"customer_center_offer" = "Tarjous"; /* Customer Center – restore */ "customer_center_restoring" = "Palautetaan…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 756a77dc2e..7f1c694e6c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID du produit"; "customer_center_store" = "Boutique"; "customer_center_sandbox" = "Bac à sable"; +"customer_center_offer" = "Offre"; /* Customer Center – restore */ "customer_center_restoring" = "Restauration en cours…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 07e6be34cc..a396369fe9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID du produit"; "customer_center_store" = "Boutique"; "customer_center_sandbox" = "Bac à sable"; +"customer_center_offer" = "Offre"; /* Customer Center – restore */ "customer_center_restoring" = "Restauration en cours…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 0d789810d1..bce7e44a7a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "מזהה מוצר"; "customer_center_store" = "חנות"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "מבצע"; /* Customer Center – restore */ "customer_center_restoring" = "משחזר…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index 45ea6b6fb8..f48858d229 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "उत्पाद आईडी"; "customer_center_store" = "स्टोर"; "customer_center_sandbox" = "सैंडबॉक्स"; +"customer_center_offer" = "ऑफ़र"; /* Customer Center – restore */ "customer_center_restoring" = "पुनर्स्थापित हो रहा है…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 61002a85b0..1a47e3db88 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID proizvoda"; "customer_center_store" = "Trgovina"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Ponuda"; /* Customer Center – restore */ "customer_center_restoring" = "Vraćanje…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index c4effa3edf..4f89bace9f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Termékazonosító"; "customer_center_store" = "Áruház"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Ajánlat"; /* Customer Center – restore */ "customer_center_restoring" = "Visszaállítás…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index 9b26feeb42..fd4d458a56 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produk"; "customer_center_store" = "Toko"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Penawaran"; /* Customer Center – restore */ "customer_center_restoring" = "Memulihkan…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 4732c871b4..4aaf24b69e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID prodotto"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Offerta"; /* Customer Center – restore */ "customer_center_restoring" = "Ripristino in corso…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index a99782b9cc..44ef4e1b9c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "製品ID"; "customer_center_store" = "ストア"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "オファー"; /* Customer Center – restore */ "customer_center_restoring" = "復元中…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index afbb5f3541..63c0f2e963 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "제품 ID"; "customer_center_store" = "스토어"; "customer_center_sandbox" = "샌드박스"; +"customer_center_offer" = "혜택"; /* Customer Center – restore */ "customer_center_restoring" = "복원 중…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 60a987fac7..cf987a2ce8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produk"; "customer_center_store" = "Kedai"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Tawaran"; /* Customer Center – restore */ "customer_center_restoring" = "Memulihkan…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index f90b880982..e52e01d77f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Produkt-ID"; "customer_center_store" = "Butikk"; "customer_center_sandbox" = "Sandkasse"; +"customer_center_offer" = "Tilbud"; /* Customer Center – restore */ "customer_center_restoring" = "Gjenoppretter…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index 7a50439caa..a3c1ea44b0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Product-ID"; "customer_center_store" = "Store"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Aanbieding"; /* Customer Center – restore */ "customer_center_restoring" = "Bezig met herstellen…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index c537d10cb5..5f50650b2b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Produkt-ID"; "customer_center_store" = "Butikk"; "customer_center_sandbox" = "Sandkasse"; +"customer_center_offer" = "Tilbod"; /* Customer Center – restore */ "customer_center_restoring" = "Gjenoppretter…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index a1e6d30324..51e3e6a22c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produktu"; "customer_center_store" = "Sklep"; "customer_center_sandbox" = "Środowisko testowe"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "Przywracanie…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index 6506195c95..c66d70c904 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID do produto"; "customer_center_store" = "Loja"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "A restaurar…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index d2b3bd64a9..9eded46779 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID do produto"; "customer_center_store" = "Loja"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "A restaurar…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index e447548a38..feb91d41f8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID do produto"; "customer_center_store" = "Loja"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Oferta"; /* Customer Center – restore */ "customer_center_restoring" = "A restaurar…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 0c9b0af95b..d41d881641 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produs"; "customer_center_store" = "Magazin"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Ofertă"; /* Customer Center – restore */ "customer_center_restoring" = "Se restaurează…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index a28bbef470..b657e7fe58 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID продукта"; "customer_center_store" = "Магазин"; "customer_center_sandbox" = "Тестовая среда"; +"customer_center_offer" = "Предложение"; /* Customer Center – restore */ "customer_center_restoring" = "Восстановление…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index a6c7d7cb0e..287b34b91b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID produktu"; "customer_center_store" = "Obchod"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Ponuka"; /* Customer Center – restore */ "customer_center_restoring" = "Obnovuje sa…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 78d9dd67da..afa8cd3ab5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID izdelka"; "customer_center_store" = "Trgovina"; "customer_center_sandbox" = "Peskovnik"; +"customer_center_offer" = "Ponudba"; /* Customer Center – restore */ "customer_center_restoring" = "Obnavljanje…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index e2d5b5ca3d..4cfe13059a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Produkt-ID"; "customer_center_store" = "Butik"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Erbjudande"; /* Customer Center – restore */ "customer_center_restoring" = "Återställer…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 1d32900ea2..0aad4f8f1c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "รหัสสินค้า"; "customer_center_store" = "ร้านค้า"; "customer_center_sandbox" = "แซนด์บ็อกซ์"; +"customer_center_offer" = "ข้อเสนอ"; /* Customer Center – restore */ "customer_center_restoring" = "กำลังกู้คืน…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index c7ac3aff83..41d9602133 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Ürün kimliği"; "customer_center_store" = "Mağaza"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Teklif"; /* Customer Center – restore */ "customer_center_restoring" = "Geri yükleniyor…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index a37a04f8a1..985984e11f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "Ідентифікатор товару"; "customer_center_store" = "Магазин"; "customer_center_sandbox" = "Тестове середовище"; +"customer_center_offer" = "Пропозиція"; /* Customer Center – restore */ "customer_center_restoring" = "Відновлення…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 5020577a45..a73a379ebc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "ID sản phẩm"; "customer_center_store" = "Cửa hàng"; "customer_center_sandbox" = "Sandbox"; +"customer_center_offer" = "Ưu đãi"; /* Customer Center – restore */ "customer_center_restoring" = "Đang khôi phục…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 19fdcbbcc5..5759e5df1b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "产品 ID"; "customer_center_store" = "商店"; "customer_center_sandbox" = "沙盒环境"; +"customer_center_offer" = "优惠"; /* Customer Center – restore */ "customer_center_restoring" = "正在恢复…"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index 8d2a930c01..b80ed8e4ef 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -108,6 +108,7 @@ "customer_center_product_id" = "產品 ID"; "customer_center_store" = "商店"; "customer_center_sandbox" = "沙盒環境"; +"customer_center_offer" = "優惠"; /* Customer Center – restore */ "customer_center_restoring" = "正在恢復…"; diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift index 73a3cc683c..48ca8a6115 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -2,7 +2,7 @@ // EntitlementProcessor.swift // SuperwallKit // -// Created by Claude on 11/09/2025. +// Created by Jordan Morgan on 11/09/2025. // // swiftlint:disable all diff --git a/Sources/SuperwallKit/Superwall+CustomerCenter.swift b/Sources/SuperwallKit/Superwall+CustomerCenter.swift index 4c1de6d52b..2d32263811 100644 --- a/Sources/SuperwallKit/Superwall+CustomerCenter.swift +++ b/Sources/SuperwallKit/Superwall+CustomerCenter.swift @@ -2,7 +2,7 @@ // Superwall+CustomerCenter.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import UIKit @@ -48,8 +48,11 @@ extension Superwall { /// 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( @@ -65,6 +68,7 @@ extension Superwall { /// 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( diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift index ce4ee58c97..4190ddd747 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift @@ -2,7 +2,7 @@ // TestModeDeviceAttributesViewController.swift // Superwall // -// Created by Claude on 2026-02-05. +// Created by Jordan Morgan on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift index 6d121c7516..ac39adba60 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift @@ -2,7 +2,7 @@ // TestModeEntitlementRowView.swift // Superwall // -// Created by Claude on 2026-02-05. +// Created by Jordan Morgan on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift index 8dc1437882..70108e0491 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift @@ -2,7 +2,7 @@ // TestModeInfoCell.swift // Superwall // -// Created by Claude on 2026-02-05. +// Created by Jordan Morgan on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift index e1423f2ad3..73dbc507d9 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift @@ -2,7 +2,7 @@ // TestModeModal.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift index 6c830b4826..e9d9939ef0 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift @@ -2,7 +2,7 @@ // TestModeModalViewController+TableView.swift // Superwall // -// Created by Claude on 2026-02-05. +// Created by Jordan Morgan on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift index b0158bef62..fd4c7cd464 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift @@ -2,7 +2,7 @@ // TestModeModalViewController.swift // Superwall // -// Created by Claude on 2026-02-05. +// Created by Jordan Morgan on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index 9f5db30447..d3d793a8d9 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -2,7 +2,7 @@ // TestModeManager.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift index 9d95615056..0bbc02ccf9 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift @@ -2,7 +2,7 @@ // TestModeManagerFactory.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift index dd605bbc6f..dfdcae60f1 100644 --- a/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift +++ b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift @@ -2,7 +2,7 @@ // TestModePurchaseDrawer.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // // swiftlint:disable file_length diff --git a/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift b/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift index 8afc4a5342..e1caa5b081 100644 --- a/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift +++ b/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift @@ -2,7 +2,7 @@ // TestModeRestoreDrawer.swift // Superwall // -// Created by Claude on 2026-02-09. +// Created by Jordan Morgan on 2026-02-09. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 8ca275948f..7e3bf425ec 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -2,7 +2,7 @@ // TestModeTransactionHandler.swift // Superwall // -// Created by Claude on 2026-01-27. +// Created by Jordan Morgan on 2026-01-27. // import UIKit diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift index a5bd749f57..18c2f3c61b 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift @@ -2,7 +2,7 @@ // AttributionTests.swift // SuperwallKit // -// Created by Claude on 13/08/2025. +// Created by Jordan Morgan on 13/08/2025. // import Testing diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift index 98839d0180..dfdba57e1e 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift @@ -14,6 +14,19 @@ struct CustomerCenterEventsTests { #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_active", presentation: "embedded") + let embeddedParams = await embedded.getSuperwallParameters() + #expect(embeddedParams["screen"] as? String == "no_active") + #expect(embeddedParams["presentation"] as? String == "embedded") + } + @Test("trackable parameters") func parameters() async { let action = InternalSuperwallEvent.CustomerCenterAction(action: .custom(identifier: "del"), pathId: "p1", productId: "prod") diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift index e1bf821d45..78d2e50754 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift @@ -2,7 +2,7 @@ // CustomerCenterManagerTests.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Testing diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift index d2cd0d69cf..7e7a692a57 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppVersionComparatorTests.swift @@ -1,3 +1,10 @@ +// +// AppVersionComparatorTests.swift +// +// +// Created by Jordan Morgan on 20/08/2026. +// + import Testing @testable import SuperwallKit diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift index 6770be08db..7432c23730 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift @@ -29,9 +29,10 @@ struct CustomerCenterPathResolverTests { 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) -> PathResolutionContext { + 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, now: now) + 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) @@ -59,6 +60,23 @@ struct CustomerCenterPathResolverTests { #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"))) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index f3396198be..ecbdb8de33 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -122,7 +122,9 @@ struct PurchasePresentationBuilderTests { ) let ent = Entitlement(id: "granted", isActive: true, store: .superwall) let rows = builder.build(customerInfo: info(subs: subs, nonSubs: [nonSub], entitlements: [ent]), products: [:]) - #expect(rows.map(\.id) == ["soon", "late", "dead", "coins", "entitlement:granted"]) + // 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") @@ -157,4 +159,58 @@ struct PurchasePresentationBuilderTests { 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" }) + } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift index 911a359d41..858a7f2186 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/SupportEmailComposerTests.swift @@ -2,7 +2,7 @@ // SupportEmailComposerTests.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Testing diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift index eff3372214..fb0492447d 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift @@ -2,7 +2,7 @@ // CustomerCenterDelegateAdapterTests.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Testing diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift index 87afcd5724..3a556b74ce 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift @@ -12,8 +12,12 @@ import Foundation 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 { diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index 72468a83d9..d084ed3f52 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -112,10 +112,29 @@ struct CustomerCenterViewModelTests { 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)") @@ -211,12 +230,111 @@ struct CustomerCenterViewModelTests { let purchase = vm.purchases[0] let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! vm.callbacks.didSelectAction = nil - // default manage path has a survey; answer it + // default manage path has a survey; answer it, then let the survey sheet finish dismissing + // so the deferred follow-up action runs await vm.select(manage, purchase: purchase) await vm.answerSurvey(optionId: "dont_use") + #expect(vm.sheet == nil) + await vm.sheetDidDismiss() #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 + + @Test("navigating within the Customer Center is not treated as a dismissal") + func navigationWithinCustomerCenterIsNotDismissal() async { + let (vm, _, _) = make(info: info([sub()])) + await vm.load() + var dismissed = false + vm.callbacks.didDismiss = { dismissed = true } + + // Embedded mode: pushing the detail/history screen removes the root view from the hierarchy. + vm.isNavigatingWithinCustomerCenter = true + vm.rootViewDidDisappear() + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(!dismissed) + + // A real disappearance still dismisses. + vm.isNavigatingWithinCustomerCenter = false + vm.rootViewDidDisappear() + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(dismissed) + } + @Test("dismiss tracks close and calls back; publisher updates re-render") func dismissAndPublisher() async { let tracker = EventTrackerMock() diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift index e0b8593f94..c1cfeaa850 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -2,7 +2,7 @@ // CustomerCenterViewSmokeTests.swift // // -// Created by Claude on 20/08/2026. +// Created by Jordan Morgan on 20/08/2026. // import Testing diff --git a/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift b/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift index 7795ed7931..8815a71440 100644 --- a/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift +++ b/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift @@ -2,7 +2,7 @@ // InternallySetSubscriptionStatusTests.swift // SuperwallKitTests // -// Created by Claude on 02/10/2025. +// Created by Jordan Morgan on 02/10/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift b/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift index 55523c9454..1c7aba4044 100644 --- a/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift +++ b/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift @@ -2,7 +2,7 @@ // PaywallPresentationInfoTests.swift // SuperwallKitTests // -// Created by Claude on 08/01/2025. +// Created by Jordan Morgan on 08/01/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift index ae1f77b0f2..1c5ee7e3a5 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift @@ -1,7 +1,7 @@ // // PresentationIdTests.swift // -// Created by Claude on 2026-03-06. +// Created by Jordan Morgan on 2026-03-06. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift b/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift index d692ae9e4f..94cc96753f 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift @@ -2,7 +2,7 @@ // StripeTrialEligibilityTests.swift // SuperwallKitTests // -// Created by Claude on 03/03/2026. +// Created by Jordan Morgan on 03/03/2026. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift index c6c844d3c1..93890cd9f4 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift @@ -2,7 +2,7 @@ // PaywallViewControllerDrawerTests.swift // SuperwallKitTests // -// Created by Claude on 08/01/2025. +// Created by Jordan Morgan on 08/01/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift index 004652644f..5c219c99c7 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift @@ -1,7 +1,7 @@ // // PageViewMessageTests.swift // -// Created by Claude on 2026-03-06. +// Created by Jordan Morgan on 2026-03-06. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift b/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift index 1aa5c1452d..145390b861 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift @@ -2,7 +2,7 @@ // ProductsFetcherSK2Tests.swift // SuperwallKit // -// Created by Claude on 27/08/2025. +// Created by Jordan Morgan on 27/08/2025. // import Testing diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift index be251bb328..a643ce393c 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift @@ -2,7 +2,7 @@ // EntitlementProcessorTests.swift // SuperwallKitTests // -// Created by Claude on 11/09/2025. +// Created by Jordan Morgan on 11/09/2025. // import Testing diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift index d4bd7e9e33..1a49f91727 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift @@ -2,7 +2,7 @@ // SubscriptionPeriodPriceTests.swift // SuperwallKitTests // -// Created by Claude on 2026-01-16. +// Created by Jordan Morgan on 2026-01-16. // // swiftlint:disable all From b2244d9769aeec2cedd7cbf2549610fd28773568 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 09:02:54 -0500 Subject: [PATCH 20/42] refactor(customer-center): rename noActiveScreen to noPurchasesScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen shows when the customer has no purchases on record at all — no subscriptions (active or expired), no one-time purchases, no active entitlements. An expired subscriber routes to the management screen, so "no active" described a case that never reaches here. The new name matches the hasAnyPurchases predicate that actually gates it. Renames the public noActiveScreen property, the internal screen state case, NoPurchasesScreenView, the customerCenterOpen event's screen value, the accessibility identifier, and the localization keys across all 41 locales (keys only — the displayed copy is unchanged). --- Examples/Advanced/Advanced/HomeView.swift | 2 +- .../Superwall Placement/SuperwallEvent.swift | 2 +- .../Models/CustomerCenterConfiguration.swift | 16 ++++++++-------- .../Models/CustomerCenterScreenState.swift | 2 +- .../ViewModel/CustomerCenterViewModel.swift | 8 ++++---- .../Views/CustomerCenterStrings+English.swift | 4 ++-- .../Views/CustomerCenterView.swift | 4 ++-- ...eenView.swift => NoPurchasesScreenView.swift} | 11 ++++++----- .../Documentation.docc/CustomerCenter.md | 2 +- .../Localizations/ar.lproj/Localizable.strings | 4 ++-- .../Localizations/ca.lproj/Localizable.strings | 4 ++-- .../Localizations/cs.lproj/Localizable.strings | 4 ++-- .../Localizations/da.lproj/Localizable.strings | 4 ++-- .../Localizations/de.lproj/Localizable.strings | 4 ++-- .../Localizations/el.lproj/Localizable.strings | 4 ++-- .../Localizations/en.lproj/Localizable.strings | 4 ++-- .../en_AU.lproj/Localizable.strings | 4 ++-- .../en_GB.lproj/Localizable.strings | 4 ++-- .../Localizations/es.lproj/Localizable.strings | 4 ++-- .../es_419.lproj/Localizable.strings | 4 ++-- .../Localizations/fi.lproj/Localizable.strings | 4 ++-- .../Localizations/fr.lproj/Localizable.strings | 4 ++-- .../fr_CA.lproj/Localizable.strings | 4 ++-- .../Localizations/he.lproj/Localizable.strings | 4 ++-- .../Localizations/hi.lproj/Localizable.strings | 4 ++-- .../Localizations/hr.lproj/Localizable.strings | 4 ++-- .../Localizations/hu.lproj/Localizable.strings | 4 ++-- .../Localizations/id.lproj/Localizable.strings | 4 ++-- .../Localizations/it.lproj/Localizable.strings | 4 ++-- .../Localizations/ja.lproj/Localizable.strings | 4 ++-- .../Localizations/ko.lproj/Localizable.strings | 4 ++-- .../Localizations/ms.lproj/Localizable.strings | 4 ++-- .../Localizations/nb.lproj/Localizable.strings | 4 ++-- .../Localizations/nl.lproj/Localizable.strings | 4 ++-- .../Localizations/nn.lproj/Localizable.strings | 4 ++-- .../Localizations/pl.lproj/Localizable.strings | 4 ++-- .../Localizations/pt.lproj/Localizable.strings | 4 ++-- .../pt_BR.lproj/Localizable.strings | 4 ++-- .../pt_PT.lproj/Localizable.strings | 4 ++-- .../Localizations/ro.lproj/Localizable.strings | 4 ++-- .../Localizations/ru.lproj/Localizable.strings | 4 ++-- .../Localizations/sk.lproj/Localizable.strings | 4 ++-- .../Localizations/sl.lproj/Localizable.strings | 4 ++-- .../Localizations/sv.lproj/Localizable.strings | 4 ++-- .../Localizations/th.lproj/Localizable.strings | 4 ++-- .../Localizations/tr.lproj/Localizable.strings | 4 ++-- .../Localizations/uk.lproj/Localizable.strings | 4 ++-- .../Localizations/vi.lproj/Localizable.strings | 4 ++-- .../zh_Hans.lproj/Localizable.strings | 4 ++-- .../zh_Hant.lproj/Localizable.strings | 4 ++-- SuperwallKit.xcodeproj/project.pbxproj | 8 ++++---- .../CustomerCenterEventsTests.swift | 4 ++-- .../CustomerCenterConfigurationTests.swift | 4 ++-- .../ViewModel/CustomerCenterViewModelTests.swift | 10 +++++----- 54 files changed, 121 insertions(+), 120 deletions(-) rename Sources/SuperwallKit/CustomerCenter/Views/{NoActiveScreenView.swift => NoPurchasesScreenView.swift} (65%) diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index d515cad34e..76035d0230 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -44,7 +44,7 @@ struct HomeView: View { .init(id: "contact_support", type: .contactSupport) ] ), - noActiveScreen: .init( + noPurchasesScreen: .init( paths: [.init(id: "restore", type: .restore)] ), support: .init(email: "support@superwall.com") diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 4be16602fb..17f0c5589b 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -359,7 +359,7 @@ public enum SuperwallEvent { /// When the test mode modal is closed. case testModeModalClose - /// When the Customer Center is presented. `screen` is `management` or `no_active`. + /// When the Customer Center is presented. `screen` is `management` or `no_purchases`. case customerCenterOpen(screen: String) /// When the Customer Center is dismissed. diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index 71b29c1e78..73e732b964 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -18,7 +18,7 @@ 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 noActiveScreen: Screen + 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. @@ -32,7 +32,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { public init( managementScreen: Screen, - noActiveScreen: Screen, + noPurchasesScreen: Screen, support: Support = Support(), appearance: Appearance = Appearance(), showsPurchaseHistory: Bool = true, @@ -40,7 +40,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { warnsAboutDuplicateSubscriptions: Bool = true ) { self.managementScreen = managementScreen - self.noActiveScreen = noActiveScreen + self.noPurchasesScreen = noPurchasesScreen self.support = support self.appearance = appearance self.showsPurchaseHistory = showsPurchaseHistory @@ -50,7 +50,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { /// 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-active screen. + /// no-purchases screen. public static var `default`: CustomerCenterConfiguration { let cancelSurvey = FeedbackSurvey( id: "cancel_survey", @@ -73,7 +73,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { Path(id: "contact_support", type: .contactSupport) ] ), - noActiveScreen: Screen( + noPurchasesScreen: Screen( title: nil, subtitle: nil, paths: [Path(id: "restore", type: .restore)] @@ -84,7 +84,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { override public func isEqual(_ object: Any?) -> Bool { guard let other = object as? CustomerCenterConfiguration else { return false } return managementScreen == other.managementScreen - && noActiveScreen == other.noActiveScreen + && noPurchasesScreen == other.noPurchasesScreen && support == other.support && appearance == other.appearance && showsPurchaseHistory == other.showsPurchaseHistory @@ -95,7 +95,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { override public var hash: Int { var hasher = Hasher() hasher.combine(managementScreen) - hasher.combine(noActiveScreen) + hasher.combine(noPurchasesScreen) hasher.combine(support) hasher.combine(appearance) hasher.combine(showsPurchaseHistory) @@ -112,7 +112,7 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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-active screen) or none (management screen). + /// 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] diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift index c04d35f488..d5c1db8d14 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift @@ -7,7 +7,7 @@ import Foundation -enum CustomerCenterScreenState: Equatable { case loading, management, noActive } +enum CustomerCenterScreenState: Equatable { case loading, management, noPurchases } enum CustomerCenterRestoreState: Equatable { case idle, restoring, restored, notFound } enum CustomerCenterSheet: Identifiable, Equatable { diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 94d7461da0..8a7017535b 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -88,7 +88,7 @@ final class CustomerCenterViewModel: ObservableObject { hasTrackedOpen = true await dependencies.tracker.track( InternalSuperwallEvent.CustomerCenterOpen( - screen: state == .management ? "management" : "no_active", + screen: state == .management ? "management" : "no_purchases", presentation: presentationMode ) ) @@ -108,7 +108,7 @@ final class CustomerCenterViewModel: ObservableObject { } let builder = PurchasePresentationBuilder(strings: strings) purchases = builder.build(customerInfo: customerInfo, products: products) - state = hasAnyPurchases(customerInfo) ? .management : .noActive + state = hasAnyPurchases(customerInfo) ? .management : .noPurchases showsUpdateBanner = !updateWarningDismissed && configuration.support.shouldWarnToUpdate && AppVersionComparator.isInstalledVersion( @@ -137,10 +137,10 @@ final class CustomerCenterViewModel: ObservableObject { /// 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-active), where + /// - 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 == .noActive ? configuration.noActiveScreen : configuration.managementScreen + let screen = state == .noPurchases ? configuration.noPurchasesScreen : configuration.managementScreen let context = PathResolutionContext( purchase: purchase, product: purchase?.productId.flatMap { products[$0] }, diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index cd05c24663..1a422f116d 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -37,8 +37,8 @@ extension CustomerCenterStrings { let englishStrings: [String: String] = [ // Customer Center – screens "customer_center_management_title": "Manage your subscription", - "customer_center_no_active_title": "No subscriptions found", - "customer_center_no_active_subtitle": "We can check for previous purchases.", + "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", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index cb253f9a4d..a5a99e077c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -140,8 +140,8 @@ public struct CustomerCenterView: View { ProgressView().accessibilityIdentifier("customer_center.loading") case .management: ManagementScreenView(viewModel: viewModel) - case .noActive: - NoActiveScreenView(viewModel: viewModel) + case .noPurchases: + NoPurchasesScreenView(viewModel: viewModel) } RestoreOverlay(viewModel: viewModel) } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift similarity index 65% rename from Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift rename to Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift index 0c8a6c0870..2d78b523e4 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/NoActiveScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/NoPurchasesScreenView.swift @@ -1,5 +1,5 @@ // -// NoActiveScreenView.swift +// NoPurchasesScreenView.swift // // // Created by Jordan Morgan on 20/08/2026. @@ -8,22 +8,23 @@ import SwiftUI @available(iOS 15.0, *) -struct NoActiveScreenView: View { +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(viewModel.configuration.noActiveScreen.title ?? strings.string("customer_center_no_active_title")) + Text(screen.title ?? strings.string("customer_center_no_purchases_title")) .font(.headline) - Text(viewModel.configuration.noActiveScreen.subtitle ?? strings.string("customer_center_no_active_subtitle")) + Text(screen.subtitle ?? strings.string("customer_center_no_purchases_subtitle")) .font(.subheadline) .foregroundStyle(.secondary) } .padding(.vertical, 4) - .accessibilityIdentifier("customer_center.no_active") + .accessibilityIdentifier("customer_center.no_purchases") } Section { PathsListView(viewModel: viewModel, purchase: nil) } if viewModel.configuration.showsAccountDetails { AccountDetailsSection(viewModel: viewModel) } diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 40f2f27ce6..17290c3e5d 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -91,7 +91,7 @@ options.customerCenter = CustomerCenterConfiguration( .init(id: "contact_support", type: .contactSupport) ] ), - noActiveScreen: .init( + noPurchasesScreen: .init( paths: [.init(id: "restore", type: .restore)] ), support: .init(email: "support@mycompany.com") diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 5ed5d2cf5b..051476b512 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "إدارة اشتراكك"; -"customer_center_no_active_title" = "لم يتم العثور على اشتراكات"; -"customer_center_no_active_subtitle" = "يمكننا التحقق من عمليات الشراء السابقة."; +"customer_center_no_purchases_title" = "لم يتم العثور على اشتراكات"; +"customer_center_no_purchases_subtitle" = "يمكننا التحقق من عمليات الشراء السابقة."; "customer_center_close" = "إغلاق"; "customer_center_done" = "تم"; "customer_center_cancel" = "إلغاء"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index d43178c064..6a2440e957 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gestiona la teva subscripció"; -"customer_center_no_active_title" = "No s'ha trobat cap subscripció"; -"customer_center_no_active_subtitle" = "Podem comprovar si hi ha compres anteriors."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 6a53234fe4..d777930a71 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Spravovat vaše předplatné"; -"customer_center_no_active_title" = "Nebylo nalezeno žádné předplatné"; -"customer_center_no_active_subtitle" = "Můžeme zkontrolovat předchozí nákupy."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 8ac36bd1de..8cbc225a1d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Administrer dit abonnement"; -"customer_center_no_active_title" = "Ingen abonnementer fundet"; -"customer_center_no_active_subtitle" = "Vi kan tjekke for tidligere køb."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index b84412b28a..0ba23307af 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Abo verwalten"; -"customer_center_no_active_title" = "Keine Abonnements gefunden"; -"customer_center_no_active_subtitle" = "Wir können nach früheren Käufen suchen."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 6932defe11..fc729d8d56 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Διαχείριση της συνδρομής σας"; -"customer_center_no_active_title" = "Δεν βρέθηκαν συνδρομές"; -"customer_center_no_active_subtitle" = "Μπορούμε να ελέγξουμε για προηγούμενες αγορές."; +"customer_center_no_purchases_title" = "Δεν βρέθηκαν συνδρομές"; +"customer_center_no_purchases_subtitle" = "Μπορούμε να ελέγξουμε για προηγούμενες αγορές."; "customer_center_close" = "Κλείσιμο"; "customer_center_done" = "Τέλος"; "customer_center_cancel" = "Ακύρωση"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index aa5fb38fc5..5621112e02 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Manage your subscription"; -"customer_center_no_active_title" = "No subscriptions found"; -"customer_center_no_active_subtitle" = "We can check for previous purchases."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index aa5fb38fc5..5621112e02 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Manage your subscription"; -"customer_center_no_active_title" = "No subscriptions found"; -"customer_center_no_active_subtitle" = "We can check for previous purchases."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index aa5fb38fc5..5621112e02 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Manage your subscription"; -"customer_center_no_active_title" = "No subscriptions found"; -"customer_center_no_active_subtitle" = "We can check for previous purchases."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index be1b5be473..bde63656a5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gestione su suscripción"; -"customer_center_no_active_title" = "No se encontraron suscripciones"; -"customer_center_no_active_subtitle" = "Podemos comprobar si hay compras anteriores."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 87ed291ab9..1914ed19c8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gestiona tu suscripción"; -"customer_center_no_active_title" = "No se encontraron suscripciones"; -"customer_center_no_active_subtitle" = "Podemos comprobar si hay compras anteriores."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 4fcf9fb941..f066618647 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Hallinnoi tilaustasi"; -"customer_center_no_active_title" = "Tilauksia ei löytynyt"; -"customer_center_no_active_subtitle" = "Voimme tarkistaa aiemmat ostokset."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 7f1c694e6c..e075998b6f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gérer votre abonnement"; -"customer_center_no_active_title" = "Aucun abonnement trouvé"; -"customer_center_no_active_subtitle" = "Nous pouvons vérifier vos achats précédents."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index a396369fe9..ab5e6f728b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gérer votre abonnement"; -"customer_center_no_active_title" = "Aucun abonnement trouvé"; -"customer_center_no_active_subtitle" = "Nous pouvons vérifier vos achats précédents."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index bce7e44a7a..7af592795a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "ניהול המנוי שלך"; -"customer_center_no_active_title" = "לא נמצאו מנויים"; -"customer_center_no_active_subtitle" = "נוכל לבדוק אם יש רכישות קודמות."; +"customer_center_no_purchases_title" = "לא נמצאו מנויים"; +"customer_center_no_purchases_subtitle" = "נוכל לבדוק אם יש רכישות קודמות."; "customer_center_close" = "סגירה"; "customer_center_done" = "סיום"; "customer_center_cancel" = "ביטול"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index f48858d229..7b6ba0f756 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "अपनी सदस्यता प्रबंधित करें"; -"customer_center_no_active_title" = "कोई सदस्यता नहीं मिली"; -"customer_center_no_active_subtitle" = "हम पिछली खरीदारी की जांच कर सकते हैं।"; +"customer_center_no_purchases_title" = "कोई सदस्यता नहीं मिली"; +"customer_center_no_purchases_subtitle" = "हम पिछली खरीदारी की जांच कर सकते हैं।"; "customer_center_close" = "बंद करें"; "customer_center_done" = "हो गया"; "customer_center_cancel" = "रद्द करें"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 1a47e3db88..7fec145610 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Upravljanje pretplatom"; -"customer_center_no_active_title" = "Nije pronađena nijedna pretplata"; -"customer_center_no_active_subtitle" = "Možemo provjeriti prethodne kupnje."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index 4f89bace9f..cfa892fad3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Előfizetés kezelése"; -"customer_center_no_active_title" = "Nem található előfizetés"; -"customer_center_no_active_subtitle" = "Ellenőrizhetjük a korábbi vásárlásokat."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index fd4d458a56..2e3122b058 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Kelola langganan Anda"; -"customer_center_no_active_title" = "Tidak ada langganan yang ditemukan"; -"customer_center_no_active_subtitle" = "Kami dapat memeriksa pembelian sebelumnya."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 4aaf24b69e..f33b9d1368 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gestisci il tuo abbonamento"; -"customer_center_no_active_title" = "Nessun abbonamento trovato"; -"customer_center_no_active_subtitle" = "Possiamo verificare la presenza di acquisti precedenti."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 44ef4e1b9c..474c46c0d5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "サブスクリプションを管理"; -"customer_center_no_active_title" = "サブスクリプションが見つかりません"; -"customer_center_no_active_subtitle" = "以前の購入を確認できます。"; +"customer_center_no_purchases_title" = "サブスクリプションが見つかりません"; +"customer_center_no_purchases_subtitle" = "以前の購入を確認できます。"; "customer_center_close" = "閉じる"; "customer_center_done" = "完了"; "customer_center_cancel" = "キャンセル"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 63c0f2e963..b1b665c310 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "구독 관리"; -"customer_center_no_active_title" = "구독을 찾을 수 없습니다"; -"customer_center_no_active_subtitle" = "이전 구매 내역을 확인할 수 있습니다."; +"customer_center_no_purchases_title" = "구독을 찾을 수 없습니다"; +"customer_center_no_purchases_subtitle" = "이전 구매 내역을 확인할 수 있습니다."; "customer_center_close" = "닫기"; "customer_center_done" = "완료"; "customer_center_cancel" = "취소"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index cf987a2ce8..758e7d68e7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Urus langganan anda"; -"customer_center_no_active_title" = "Tiada langganan ditemui"; -"customer_center_no_active_subtitle" = "Kami boleh menyemak pembelian terdahulu."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index e52e01d77f..d54210053e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Administrer abonnementet ditt"; -"customer_center_no_active_title" = "Fant ingen abonnementer"; -"customer_center_no_active_subtitle" = "Vi kan sjekke etter tidligere kjøp."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index a3c1ea44b0..f1cfdf05e5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Beheer uw abonnement"; -"customer_center_no_active_title" = "Geen abonnementen gevonden"; -"customer_center_no_active_subtitle" = "We kunnen controleren op eerdere aankopen."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 5f50650b2b..6184a1d2a3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Administrer abonnementet ditt"; -"customer_center_no_active_title" = "Fant ingen abonnementer"; -"customer_center_no_active_subtitle" = "Vi kan sjekke etter tidligere kjøp."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 51e3e6a22c..86db359348 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Zarządzaj subskrypcją"; -"customer_center_no_active_title" = "Nie znaleziono subskrypcji"; -"customer_center_no_active_subtitle" = "Możemy sprawdzić poprzednie zakupy."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index c66d70c904..a958833137 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gerir a sua subscrição"; -"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; -"customer_center_no_active_subtitle" = "Podemos verificar compras anteriores."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 9eded46779..7a406ff429 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gerir a sua subscrição"; -"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; -"customer_center_no_active_subtitle" = "Podemos verificar compras anteriores."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index feb91d41f8..a1e66a765f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gerir a sua subscrição"; -"customer_center_no_active_title" = "Nenhuma subscrição encontrada"; -"customer_center_no_active_subtitle" = "Podemos verificar compras anteriores."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index d41d881641..885b905d77 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Gestionați-vă abonamentul"; -"customer_center_no_active_title" = "Nu s-a găsit niciun abonament"; -"customer_center_no_active_subtitle" = "Putem verifica achizițiile anterioare."; +"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ă"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index b657e7fe58..d645a4ca35 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Управление подпиской"; -"customer_center_no_active_title" = "Подписки не найдены"; -"customer_center_no_active_subtitle" = "Мы можем проверить наличие предыдущих покупок."; +"customer_center_no_purchases_title" = "Подписки не найдены"; +"customer_center_no_purchases_subtitle" = "Мы можем проверить наличие предыдущих покупок."; "customer_center_close" = "Закрыть"; "customer_center_done" = "Готово"; "customer_center_cancel" = "Отмена"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index 287b34b91b..e8314e12ed 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Spravovať predplatné"; -"customer_center_no_active_title" = "Nenašlo sa žiadne predplatné"; -"customer_center_no_active_subtitle" = "Môžeme skontrolovať predchádzajúce nákupy."; +"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ť"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index afa8cd3ab5..b42089bde3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Upravljanje naročnine"; -"customer_center_no_active_title" = "Ni najdenih naročnin"; -"customer_center_no_active_subtitle" = "Preverimo lahko prejšnje nakupe."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index 4cfe13059a..46521bd908 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Hantera din prenumeration"; -"customer_center_no_active_title" = "Inga prenumerationer hittades"; -"customer_center_no_active_subtitle" = "Vi kan kontrollera om det finns tidigare köp."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 0aad4f8f1c..bff1e1e64f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "จัดการการสมัครสมาชิกของคุณ"; -"customer_center_no_active_title" = "ไม่พบการสมัครสมาชิก"; -"customer_center_no_active_subtitle" = "เราสามารถตรวจสอบการซื้อก่อนหน้านี้ได้"; +"customer_center_no_purchases_title" = "ไม่พบการสมัครสมาชิก"; +"customer_center_no_purchases_subtitle" = "เราสามารถตรวจสอบการซื้อก่อนหน้านี้ได้"; "customer_center_close" = "ปิด"; "customer_center_done" = "เสร็จสิ้น"; "customer_center_cancel" = "ยกเลิก"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 41d9602133..cd735b96b5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Aboneliğinizi yönetin"; -"customer_center_no_active_title" = "Abonelik bulunamadı"; -"customer_center_no_active_subtitle" = "Önceki satın alımlarınızı kontrol edebiliriz."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 985984e11f..7d8355ac20 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Керування підпискою"; -"customer_center_no_active_title" = "Підписок не знайдено"; -"customer_center_no_active_subtitle" = "Ми можемо перевірити попередні покупки."; +"customer_center_no_purchases_title" = "Підписок не знайдено"; +"customer_center_no_purchases_subtitle" = "Ми можемо перевірити попередні покупки."; "customer_center_close" = "Закрити"; "customer_center_done" = "Готово"; "customer_center_cancel" = "Скасувати"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index a73a379ebc..0fff98669b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "Quản lý gói đăng ký của bạn"; -"customer_center_no_active_title" = "Không tìm thấy gói đăng ký nào"; -"customer_center_no_active_subtitle" = "Chúng tôi có thể kiểm tra các giao dịch mua trước đó."; +"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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 5759e5df1b..d78d342d08 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "管理您的订阅"; -"customer_center_no_active_title" = "未找到订阅"; -"customer_center_no_active_subtitle" = "我们可以检查以前的购买记录。"; +"customer_center_no_purchases_title" = "未找到订阅"; +"customer_center_no_purchases_subtitle" = "我们可以检查以前的购买记录。"; "customer_center_close" = "关闭"; "customer_center_done" = "完成"; "customer_center_cancel" = "取消"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index b80ed8e4ef..926edd7bfe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -39,8 +39,8 @@ /* Customer Center – screens */ "customer_center_management_title" = "管理您的訂閱"; -"customer_center_no_active_title" = "找不到訂閱"; -"customer_center_no_active_subtitle" = "我們可以查詢先前的購買記錄。"; +"customer_center_no_purchases_title" = "找不到訂閱"; +"customer_center_no_purchases_subtitle" = "我們可以查詢先前的購買記錄。"; "customer_center_close" = "關閉"; "customer_center_done" = "完成"; "customer_center_cancel" = "取消"; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 6faa231b25..abd16c53be 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -42,7 +42,6 @@ 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 */; }; - 1058373F886FEBA381C4B1E8 /* NoActiveScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10863EC29BADB2823086E14D /* NoActiveScreenView.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 */; }; @@ -378,6 +377,7 @@ 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 */; }; @@ -679,7 +679,6 @@ 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2ObserverModePurchaseDetector.swift; sourceTree = ""; }; 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2Tests.swift; sourceTree = ""; }; 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+SHA256.swift"; sourceTree = ""; }; - 10863EC29BADB2823086E14D /* NoActiveScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoActiveScreenView.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 = ""; }; @@ -1116,6 +1115,7 @@ 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 = ""; }; @@ -1465,7 +1465,7 @@ 47D400B1629D13D5BF38370B /* DuplicateSubscriptionBanner.swift */, 9B079AAB038F2DE800E71AD8 /* FeedbackSurveyView.swift */, B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */, - 10863EC29BADB2823086E14D /* NoActiveScreenView.swift */, + B95FB737271FE6289A0A4BD1 /* NoPurchasesScreenView.swift */, 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */, A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */, E705F9954F808C341A4D0EBD /* PurchaseHistoryView.swift */, @@ -3886,7 +3886,7 @@ E3DC0E7597234DC8CC508A33 /* MapSwiftErrors.swift in Sources */, 07862D18809FA5DEA95AE440 /* NSManagedObjectContext+mergeChanges.swift in Sources */, 2698874EEAE37BAECE7B8FD8 /* Network.swift in Sources */, - 1058373F886FEBA381C4B1E8 /* NoActiveScreenView.swift in Sources */, + A50C9EFDE9ED8778BB4C44D3 /* NoPurchasesScreenView.swift in Sources */, 32C1A7BB48AC2A5CB88C448B /* NonSubscriptionTransaction.swift in Sources */, 753FBF77D03B954DCE963A52 /* NotificationProtocols.swift in Sources */, 0B5A0C6EA2D1C98B32110FD9 /* NotificationScheduler.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift index dfdba57e1e..3fc0497100 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterEventsTests.swift @@ -21,9 +21,9 @@ struct CustomerCenterEventsTests { #expect(sheetParams["screen"] as? String == "management") #expect(sheetParams["presentation"] as? String == "sheet") - let embedded = InternalSuperwallEvent.CustomerCenterOpen(screen: "no_active", presentation: "embedded") + let embedded = InternalSuperwallEvent.CustomerCenterOpen(screen: "no_purchases", presentation: "embedded") let embeddedParams = await embedded.getSuperwallParameters() - #expect(embeddedParams["screen"] as? String == "no_active") + #expect(embeddedParams["screen"] as? String == "no_purchases") #expect(embeddedParams["presentation"] as? String == "embedded") } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift index 5b868597ee..e6ca05c590 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift @@ -4,11 +4,11 @@ import Foundation @Suite("CustomerCenterConfiguration") struct CustomerCenterConfigurationTests { - @Test("default has management paths restore/changePlan/refund/manage(with survey)/contactSupport and no-active restore") + @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.noActiveScreen.paths.map(\.id) == ["restore"]) + #expect(config.noPurchasesScreen.paths.map(\.id) == ["restore"]) let manage = config.managementScreen.paths[3] #expect(manage.type == .manageSubscription) #expect(manage.survey?.id == "cancel_survey") diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index d084ed3f52..4b62e8cfde 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -50,11 +50,11 @@ struct CustomerCenterViewModelTests { } } - @Test("load: no purchases → noActive") - func loadNoActive() async { + @Test("load: no purchases → noPurchases") + func loadNoPurchases() async { let (vm, _, _) = make(info: info([])) await vm.load() - #expect(vm.state == .noActive) + #expect(vm.state == .noPurchases) } @Test("update banner only when latestAppVersion is newer and warn enabled") @@ -164,7 +164,7 @@ struct CustomerCenterViewModelTests { let restorer = RestorerMock() let (vm, infoMock, _) = make(info: info([]), restorer: restorer) await vm.load() - #expect(vm.state == .noActive) + #expect(vm.state == .noPurchases) let entitlementOnlyInfo = CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: [Entitlement(id: "premium")]) infoMock.subject.value = entitlementOnlyInfo await vm.performRestore() @@ -340,7 +340,7 @@ struct CustomerCenterViewModelTests { let tracker = EventTrackerMock() let (vm, infoMock, _) = make(info: info([]), tracker: tracker) await vm.load() - #expect(vm.state == .noActive) + #expect(vm.state == .noPurchases) infoMock.subject.value = info([sub()]) try? await Task.sleep(nanoseconds: 100_000_000) #expect(vm.state == .management) From 9b19ab3db2adcd7a9f6fab6c5852aba6f26e657d Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 10:21:44 -0500 Subject: [PATCH 21/42] revert(customer-center): restore authorship headers on unrelated files The final review pass rewrote "Created by Claude" to "Created by Jordan Morgan" across the whole repo when it should have been scoped to the files this feature adds. That touched 24 pre-existing files (TestMode, V2ProductsResponse, TestStoreUser, EntitlementProcessor and several test files) that have nothing to do with the Customer Center. Restores them to their state on develop; the header fix stands only on Customer Center files. --- Sources/SuperwallKit/Config/Models/TestStoreUser.swift | 2 +- Sources/SuperwallKit/Network/V2ProductsResponse.swift | 2 +- .../Products/Receipt Manager/EntitlementProcessor.swift | 2 +- .../TestMode/Alert/TestModeDeviceAttributesViewController.swift | 2 +- .../TestMode/Alert/TestModeEntitlementRowView.swift | 2 +- Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift | 2 +- Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift | 2 +- .../TestMode/Alert/TestModeModalViewController+TableView.swift | 2 +- .../TestMode/Alert/TestModeModalViewController.swift | 2 +- Sources/SuperwallKit/TestMode/TestModeManager.swift | 2 +- Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift | 2 +- Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift | 2 +- Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift | 2 +- Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift | 2 +- .../Analytics/Attribution/AttributionTests.swift | 2 +- .../InternallySetSubscriptionStatusTests.swift | 2 +- .../SuperwallKitTests/Models/PaywallPresentationInfoTests.swift | 2 +- .../Paywall/Presentation/PresentationIdTests.swift | 2 +- .../Paywall/Request/StripeTrialEligibilityTests.swift | 2 +- .../View Controller/PaywallViewControllerDrawerTests.swift | 2 +- .../Web View/Message Handling/PageViewMessageTests.swift | 2 +- .../StoreKit/Products/ProductsFetcherSK2Tests.swift | 2 +- .../Products/Receipt Manager/EntitlementProcessorTests.swift | 2 +- .../Products/StoreProduct/SubscriptionPeriodPriceTests.swift | 2 +- 24 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/SuperwallKit/Config/Models/TestStoreUser.swift b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift index dc4e2aebaf..74f6338c3a 100644 --- a/Sources/SuperwallKit/Config/Models/TestStoreUser.swift +++ b/Sources/SuperwallKit/Config/Models/TestStoreUser.swift @@ -2,7 +2,7 @@ // TestStoreUser.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/Network/V2ProductsResponse.swift b/Sources/SuperwallKit/Network/V2ProductsResponse.swift index 4b24126bd1..07b98f8011 100644 --- a/Sources/SuperwallKit/Network/V2ProductsResponse.swift +++ b/Sources/SuperwallKit/Network/V2ProductsResponse.swift @@ -2,7 +2,7 @@ // SuperwallProductsResponse.swift // Superwall // -// Created by Jordan Morgan on 2026-01-26. +// Created by Claude on 2026-01-26. // import Foundation diff --git a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift index 48ca8a6115..73a3cc683c 100644 --- a/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift +++ b/Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift @@ -2,7 +2,7 @@ // EntitlementProcessor.swift // SuperwallKit // -// Created by Jordan Morgan on 11/09/2025. +// Created by Claude on 11/09/2025. // // swiftlint:disable all diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift index 4190ddd747..ce4ee58c97 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeDeviceAttributesViewController.swift @@ -2,7 +2,7 @@ // TestModeDeviceAttributesViewController.swift // Superwall // -// Created by Jordan Morgan on 2026-02-05. +// Created by Claude on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift index ac39adba60..6d121c7516 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeEntitlementRowView.swift @@ -2,7 +2,7 @@ // TestModeEntitlementRowView.swift // Superwall // -// Created by Jordan Morgan on 2026-02-05. +// Created by Claude on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift index 70108e0491..8dc1437882 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeInfoCell.swift @@ -2,7 +2,7 @@ // TestModeInfoCell.swift // Superwall // -// Created by Jordan Morgan on 2026-02-05. +// Created by Claude on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift index 73dbc507d9..e1423f2ad3 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModal.swift @@ -2,7 +2,7 @@ // TestModeModal.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift index e9d9939ef0..6c830b4826 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController+TableView.swift @@ -2,7 +2,7 @@ // TestModeModalViewController+TableView.swift // Superwall // -// Created by Jordan Morgan on 2026-02-05. +// Created by Claude on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift index fd4c7cd464..b0158bef62 100644 --- a/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift +++ b/Sources/SuperwallKit/TestMode/Alert/TestModeModalViewController.swift @@ -2,7 +2,7 @@ // TestModeModalViewController.swift // Superwall // -// Created by Jordan Morgan on 2026-02-05. +// Created by Claude on 2026-02-05. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift index d3d793a8d9..9f5db30447 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManager.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift @@ -2,7 +2,7 @@ // TestModeManager.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift index 0bbc02ccf9..9d95615056 100644 --- a/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift +++ b/Sources/SuperwallKit/TestMode/TestModeManagerFactory.swift @@ -2,7 +2,7 @@ // TestModeManagerFactory.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // import Foundation diff --git a/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift index dfdcae60f1..dd605bbc6f 100644 --- a/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift +++ b/Sources/SuperwallKit/TestMode/TestModePurchaseDrawer.swift @@ -2,7 +2,7 @@ // TestModePurchaseDrawer.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // // swiftlint:disable file_length diff --git a/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift b/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift index e1caa5b081..8afc4a5342 100644 --- a/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift +++ b/Sources/SuperwallKit/TestMode/TestModeRestoreDrawer.swift @@ -2,7 +2,7 @@ // TestModeRestoreDrawer.swift // Superwall // -// Created by Jordan Morgan on 2026-02-09. +// Created by Claude on 2026-02-09. // import UIKit diff --git a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift index 7e3bf425ec..8ca275948f 100644 --- a/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift +++ b/Sources/SuperwallKit/TestMode/TestModeTransactionHandler.swift @@ -2,7 +2,7 @@ // TestModeTransactionHandler.swift // Superwall // -// Created by Jordan Morgan on 2026-01-27. +// Created by Claude on 2026-01-27. // import UIKit diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift index 18c2f3c61b..a5bd749f57 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AttributionTests.swift @@ -2,7 +2,7 @@ // AttributionTests.swift // SuperwallKit // -// Created by Jordan Morgan on 13/08/2025. +// Created by Claude on 13/08/2025. // import Testing diff --git a/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift b/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift index 8815a71440..7795ed7931 100644 --- a/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift +++ b/Tests/SuperwallKitTests/InternallySetSubscriptionStatusTests.swift @@ -2,7 +2,7 @@ // InternallySetSubscriptionStatusTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 02/10/2025. +// Created by Claude on 02/10/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift b/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift index 1c7aba4044..55523c9454 100644 --- a/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift +++ b/Tests/SuperwallKitTests/Models/PaywallPresentationInfoTests.swift @@ -2,7 +2,7 @@ // PaywallPresentationInfoTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 08/01/2025. +// Created by Claude on 08/01/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift index 1c5ee7e3a5..ae1f77b0f2 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift @@ -1,7 +1,7 @@ // // PresentationIdTests.swift // -// Created by Jordan Morgan on 2026-03-06. +// Created by Claude on 2026-03-06. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift b/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift index 94cc96753f..d692ae9e4f 100644 --- a/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Request/StripeTrialEligibilityTests.swift @@ -2,7 +2,7 @@ // StripeTrialEligibilityTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 03/03/2026. +// Created by Claude on 03/03/2026. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift index 93890cd9f4..c6c844d3c1 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerDrawerTests.swift @@ -2,7 +2,7 @@ // PaywallViewControllerDrawerTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 08/01/2025. +// Created by Claude on 08/01/2025. // import Testing diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift index 5c219c99c7..004652644f 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift @@ -1,7 +1,7 @@ // // PageViewMessageTests.swift // -// Created by Jordan Morgan on 2026-03-06. +// Created by Claude on 2026-03-06. // // swiftlint:disable all diff --git a/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift b/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift index 145390b861..1aa5c1452d 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/ProductsFetcherSK2Tests.swift @@ -2,7 +2,7 @@ // ProductsFetcherSK2Tests.swift // SuperwallKit // -// Created by Jordan Morgan on 27/08/2025. +// Created by Claude on 27/08/2025. // import Testing diff --git a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift index a643ce393c..be251bb328 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/Receipt Manager/EntitlementProcessorTests.swift @@ -2,7 +2,7 @@ // EntitlementProcessorTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 11/09/2025. +// Created by Claude on 11/09/2025. // import Testing diff --git a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift index 1a49f91727..d4bd7e9e33 100644 --- a/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift +++ b/Tests/SuperwallKitTests/StoreKit/Products/StoreProduct/SubscriptionPeriodPriceTests.swift @@ -2,7 +2,7 @@ // SubscriptionPeriodPriceTests.swift // SuperwallKitTests // -// Created by Jordan Morgan on 2026-01-16. +// Created by Claude on 2026-01-16. // // swiftlint:disable all From e8b454f0f56fde4ea57e38d9e16bf2f4ca872723 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 11:26:21 -0500 Subject: [PATCH 22/42] fix(customer-center): rename the SwiftUI modifier to presentSuperwallCustomerCenter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RevenueCatUI puts a presentCustomerCenter modifier on View with every parameter after isPresented defaulted, and so did we. Verified empirically by building a target that imports SuperwallKit, RevenueCat and RevenueCatUI: with the shared name, a bare .presentCustomerCenter(isPresented:) call compiled without error and silently resolved to SuperwallKit's — Swift's solver penalises each defaulted argument it fills, and ours fills 2 against RevenueCat's 13. An existing RevenueCat customer adding SuperwallKit would have had their Customer Center silently swapped for ours, with no diagnostic. Renaming the modifier makes each resolve to its own module. Confirmed by demangling the linked symbols: presentCustomerCenter -> RevenueCatUI, presentSuperwallCustomerCenter -> SuperwallKit. Objective-C was already safe (RC* vs SWK* prefixes, so no duplicate class registration at load, which @available could not have prevented). The four shared Swift type names (CustomerCenterView, CustomerCenterViewController, CustomerCenterNavigationOptions, CustomerCenterAction) stay as they are — module qualification resolves those, and it is idiomatic Swift. Superwall.shared.presentCustomerCenter() is unchanged; it is on our own type and cannot collide. --- .../CustomerCenter/SwiftUI/View+CustomerCenter.swift | 9 ++++++++- .../SuperwallKit/Documentation.docc/CustomerCenter.md | 4 ++-- .../Views/CustomerCenterViewSmokeTests.swift | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift index cb2b2209a9..52b882781c 100644 --- a/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift +++ b/Sources/SuperwallKit/CustomerCenter/SwiftUI/View+CustomerCenter.swift @@ -29,11 +29,18 @@ extension EnvironmentValues { @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 presentCustomerCenter( + func presentSuperwallCustomerCenter( isPresented: Binding, configuration: CustomerCenterConfiguration? = nil, onDismiss: (() -> Void)? = nil diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 17290c3e5d..5771f4b60b 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -28,7 +28,7 @@ present(customerCenter, animated: true) ### Presenting from SwiftUI -Use the ``SwiftUICore/View/presentCustomerCenter(isPresented:configuration:onDismiss:)`` modifier to present it as a sheet: +Use the ``SwiftUICore/View/presentSuperwallCustomerCenter(isPresented:configuration:onDismiss:)`` modifier to present it as a sheet: ```swift struct SettingsView: View { @@ -38,7 +38,7 @@ struct SettingsView: View { Button("Manage Subscription") { showsCustomerCenter = true } - .presentCustomerCenter(isPresented: $showsCustomerCenter) + .presentSuperwallCustomerCenter(isPresented: $showsCustomerCenter) } } ``` diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift index c1cfeaa850..27ae224804 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -89,13 +89,13 @@ struct CustomerCenterViewSmokeTests { } } - @Test("presentCustomerCenter(isPresented:) compiles and hosts") + @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") }.presentCustomerCenter(isPresented: $isPresented) + NavigationView { Text("Root") }.presentSuperwallCustomerCenter(isPresented: $isPresented) } } let host = UIHostingController(rootView: Host()) From 23559e3d8c3c27074adce27c5141c32f628f99fa Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 11:53:16 -0500 Subject: [PATCH 23/42] fix(customer-center): label the cancel path "Cancel subscription" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the default configuration, the .manageSubscription path carries the cancellation survey and leads to Apple's manage-subscriptions sheet, so its job is cancelling, not general management. "Manage subscription" overstated what the row does. The key customer_center_path_manage_subscription is unchanged since it tracks the PathType.manageSubscription case, not the displayed text — only the string values change, across englishStrings and all 41 Localizable.strings locales. Each locale uses its subscription-termination verb (e.g. German "kündigen", French "résilier", Japanese "解約", Dutch "opzeggen", Italian "disdire", Croatian "otkazati", Danish/Norwegian "si/sei opp") rather than reusing customer_center_cancel's dialog-dismiss word, except where a language genuinely shares one verb for both senses (e.g. Spanish, Portuguese, Polish, Czech, Vietnamese, Thai, Korean, Chinese), confirmed against each file's existing register. --- .../CustomerCenter/Views/CustomerCenterStrings+English.swift | 5 ++++- .../Resources/Localizations/ar.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ca.lproj/Localizable.strings | 2 +- .../Resources/Localizations/cs.lproj/Localizable.strings | 2 +- .../Resources/Localizations/da.lproj/Localizable.strings | 2 +- .../Resources/Localizations/de.lproj/Localizable.strings | 2 +- .../Resources/Localizations/el.lproj/Localizable.strings | 2 +- .../Resources/Localizations/en.lproj/Localizable.strings | 2 +- .../Resources/Localizations/en_AU.lproj/Localizable.strings | 2 +- .../Resources/Localizations/en_GB.lproj/Localizable.strings | 2 +- .../Resources/Localizations/es.lproj/Localizable.strings | 2 +- .../Resources/Localizations/es_419.lproj/Localizable.strings | 2 +- .../Resources/Localizations/fi.lproj/Localizable.strings | 2 +- .../Resources/Localizations/fr.lproj/Localizable.strings | 2 +- .../Resources/Localizations/fr_CA.lproj/Localizable.strings | 2 +- .../Resources/Localizations/he.lproj/Localizable.strings | 2 +- .../Resources/Localizations/hi.lproj/Localizable.strings | 2 +- .../Resources/Localizations/hr.lproj/Localizable.strings | 2 +- .../Resources/Localizations/hu.lproj/Localizable.strings | 2 +- .../Resources/Localizations/id.lproj/Localizable.strings | 2 +- .../Resources/Localizations/it.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ja.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ko.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ms.lproj/Localizable.strings | 2 +- .../Resources/Localizations/nb.lproj/Localizable.strings | 2 +- .../Resources/Localizations/nl.lproj/Localizable.strings | 2 +- .../Resources/Localizations/nn.lproj/Localizable.strings | 2 +- .../Resources/Localizations/pl.lproj/Localizable.strings | 2 +- .../Resources/Localizations/pt.lproj/Localizable.strings | 2 +- .../Resources/Localizations/pt_BR.lproj/Localizable.strings | 2 +- .../Resources/Localizations/pt_PT.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ro.lproj/Localizable.strings | 2 +- .../Resources/Localizations/ru.lproj/Localizable.strings | 2 +- .../Resources/Localizations/sk.lproj/Localizable.strings | 2 +- .../Resources/Localizations/sl.lproj/Localizable.strings | 2 +- .../Resources/Localizations/sv.lproj/Localizable.strings | 2 +- .../Resources/Localizations/th.lproj/Localizable.strings | 2 +- .../Resources/Localizations/tr.lproj/Localizable.strings | 2 +- .../Resources/Localizations/uk.lproj/Localizable.strings | 2 +- .../Resources/Localizations/vi.lproj/Localizable.strings | 2 +- .../Localizations/zh_Hans.lproj/Localizable.strings | 2 +- .../Localizations/zh_Hant.lproj/Localizable.strings | 2 +- 42 files changed, 45 insertions(+), 42 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 1a422f116d..cd57756ea6 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -44,7 +44,10 @@ let englishStrings: [String: String] = [ "customer_center_cancel": "Cancel", // Customer Center – paths "customer_center_path_restore": "Restore purchases", - "customer_center_path_manage_subscription": "Manage subscription", + // 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_refund": "Request a refund", "customer_center_path_change_plan": "Change plan", "customer_center_path_contact_support": "Contact support", diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 051476b512..7beec33cf2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "استعادة المشتريات"; -"customer_center_path_manage_subscription" = "إدارة الاشتراك"; +"customer_center_path_manage_subscription" = "إلغاء الاشتراك"; "customer_center_path_refund" = "طلب استرداد الأموال"; "customer_center_path_change_plan" = "تغيير الخطة"; "customer_center_path_contact_support" = "التواصل مع الدعم"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index 6a2440e957..6a5e50bbca 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaura les compres"; -"customer_center_path_manage_subscription" = "Gestiona la subscripció"; +"customer_center_path_manage_subscription" = "Cancel·la la subscripció"; "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index d777930a71..f5c1ba3f7a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Obnovit nákupy"; -"customer_center_path_manage_subscription" = "Spravovat předplatné"; +"customer_center_path_manage_subscription" = "Zrušit předplatné"; "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 8cbc225a1d..4746ccb920 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Gendan køb"; -"customer_center_path_manage_subscription" = "Administrer abonnement"; +"customer_center_path_manage_subscription" = "Opsig abonnement"; "customer_center_path_refund" = "Anmod om refundering"; "customer_center_path_change_plan" = "Skift abonnement"; "customer_center_path_contact_support" = "Kontakt support"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 0ba23307af..0e33205e7a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Käufe wiederherstellen"; -"customer_center_path_manage_subscription" = "Abo verwalten"; +"customer_center_path_manage_subscription" = "Abo kündigen"; "customer_center_path_refund" = "Rückerstattung anfordern"; "customer_center_path_change_plan" = "Tarif ändern"; "customer_center_path_contact_support" = "Support kontaktieren"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index fc729d8d56..46b25dc248 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Επαναφορά αγορών"; -"customer_center_path_manage_subscription" = "Διαχείριση συνδρομής"; +"customer_center_path_manage_subscription" = "Ακύρωση συνδρομής"; "customer_center_path_refund" = "Αίτημα επιστροφής χρημάτων"; "customer_center_path_change_plan" = "Αλλαγή πλάνου"; "customer_center_path_contact_support" = "Επικοινωνία με την υποστήριξη"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 5621112e02..5c0c574325 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restore purchases"; -"customer_center_path_manage_subscription" = "Manage subscription"; +"customer_center_path_manage_subscription" = "Cancel subscription"; "customer_center_path_refund" = "Request a refund"; "customer_center_path_change_plan" = "Change plan"; "customer_center_path_contact_support" = "Contact support"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 5621112e02..5c0c574325 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restore purchases"; -"customer_center_path_manage_subscription" = "Manage subscription"; +"customer_center_path_manage_subscription" = "Cancel subscription"; "customer_center_path_refund" = "Request a refund"; "customer_center_path_change_plan" = "Change plan"; "customer_center_path_contact_support" = "Contact support"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 5621112e02..5c0c574325 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restore purchases"; -"customer_center_path_manage_subscription" = "Manage subscription"; +"customer_center_path_manage_subscription" = "Cancel subscription"; "customer_center_path_refund" = "Request a refund"; "customer_center_path_change_plan" = "Change plan"; "customer_center_path_contact_support" = "Contact support"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index bde63656a5..0079da22c3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurar compras"; -"customer_center_path_manage_subscription" = "Gestionar suscripción"; +"customer_center_path_manage_subscription" = "Cancelar suscripción"; "customer_center_path_refund" = "Solicitar un reembolso"; "customer_center_path_change_plan" = "Cambiar de plan"; "customer_center_path_contact_support" = "Contactar con soporte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 1914ed19c8..894f8ac43e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurar compras"; -"customer_center_path_manage_subscription" = "Gestionar suscripción"; +"customer_center_path_manage_subscription" = "Cancelar suscripción"; "customer_center_path_refund" = "Solicitar un reembolso"; "customer_center_path_change_plan" = "Cambiar de plan"; "customer_center_path_contact_support" = "Contactar con soporte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index f066618647..031e9bd31d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Palauta ostokset"; -"customer_center_path_manage_subscription" = "Hallinnoi tilausta"; +"customer_center_path_manage_subscription" = "Peruuta tilaus"; "customer_center_path_refund" = "Pyydä hyvitystä"; "customer_center_path_change_plan" = "Vaihda tilaustasoa"; "customer_center_path_contact_support" = "Ota yhteyttä tukeen"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index e075998b6f..7ac7c7eb6c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurer les achats"; -"customer_center_path_manage_subscription" = "Gérer l'abonnement"; +"customer_center_path_manage_subscription" = "Résilier l'abonnement"; "customer_center_path_refund" = "Demander un remboursement"; "customer_center_path_change_plan" = "Changer de formule"; "customer_center_path_contact_support" = "Contacter l'assistance"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index ab5e6f728b..836707b6a0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurer les achats"; -"customer_center_path_manage_subscription" = "Gérer l'abonnement"; +"customer_center_path_manage_subscription" = "Résilier l'abonnement"; "customer_center_path_refund" = "Demander un remboursement"; "customer_center_path_change_plan" = "Changer de formule"; "customer_center_path_contact_support" = "Contacter l'assistance"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 7af592795a..7400cbc7bf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "שחזור רכישות"; -"customer_center_path_manage_subscription" = "ניהול המנוי"; +"customer_center_path_manage_subscription" = "ביטול המנוי"; "customer_center_path_refund" = "בקשת החזר כספי"; "customer_center_path_change_plan" = "שינוי תוכנית"; "customer_center_path_contact_support" = "יצירת קשר עם התמיכה"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index 7b6ba0f756..a6ca0a603b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "खरीदारी पुनर्स्थापित करें"; -"customer_center_path_manage_subscription" = "सदस्यता प्रबंधित करें"; +"customer_center_path_manage_subscription" = "सदस्यता रद्द करें"; "customer_center_path_refund" = "रिफंड का अनुरोध करें"; "customer_center_path_change_plan" = "प्लान बदलें"; "customer_center_path_contact_support" = "सहायता से संपर्क करें"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 7fec145610..d86ea3b7e6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Vrati kupnje"; -"customer_center_path_manage_subscription" = "Upravljanje pretplatom"; +"customer_center_path_manage_subscription" = "Otkazivanje pretplate"; "customer_center_path_refund" = "Zatraži povrat novca"; "customer_center_path_change_plan" = "Promijeni plan"; "customer_center_path_contact_support" = "Kontaktiraj podršku"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index cfa892fad3..effacbd81a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Vásárlások visszaállítása"; -"customer_center_path_manage_subscription" = "Előfizetés kezelése"; +"customer_center_path_manage_subscription" = "Előfizetés lemondása"; "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index 2e3122b058..e85cb90103 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Pulihkan pembelian"; -"customer_center_path_manage_subscription" = "Kelola langganan"; +"customer_center_path_manage_subscription" = "Batalkan langganan"; "customer_center_path_refund" = "Ajukan pengembalian dana"; "customer_center_path_change_plan" = "Ubah paket"; "customer_center_path_contact_support" = "Hubungi dukungan"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index f33b9d1368..681c8e04e9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Ripristina acquisti"; -"customer_center_path_manage_subscription" = "Gestisci abbonamento"; +"customer_center_path_manage_subscription" = "Disdici abbonamento"; "customer_center_path_refund" = "Richiedi un rimborso"; "customer_center_path_change_plan" = "Cambia piano"; "customer_center_path_contact_support" = "Contatta l'assistenza"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 474c46c0d5..cc94085595 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "購入を復元"; -"customer_center_path_manage_subscription" = "サブスクリプションを管理"; +"customer_center_path_manage_subscription" = "サブスクリプションを解約"; "customer_center_path_refund" = "返金をリクエスト"; "customer_center_path_change_plan" = "プランを変更"; "customer_center_path_contact_support" = "サポートに問い合わせる"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index b1b665c310..37d40c0716 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "구매 항목 복원"; -"customer_center_path_manage_subscription" = "구독 관리"; +"customer_center_path_manage_subscription" = "구독 취소"; "customer_center_path_refund" = "환불 요청"; "customer_center_path_change_plan" = "요금제 변경"; "customer_center_path_contact_support" = "지원팀에 문의"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 758e7d68e7..f205fa6adc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Pulihkan pembelian"; -"customer_center_path_manage_subscription" = "Urus langganan"; +"customer_center_path_manage_subscription" = "Batalkan langganan"; "customer_center_path_refund" = "Mohon bayaran balik"; "customer_center_path_change_plan" = "Tukar pelan"; "customer_center_path_contact_support" = "Hubungi sokongan"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index d54210053e..c86c44b1fd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Gjenopprett kjøp"; -"customer_center_path_manage_subscription" = "Administrer abonnement"; +"customer_center_path_manage_subscription" = "Si opp abonnement"; "customer_center_path_refund" = "Be om refusjon"; "customer_center_path_change_plan" = "Endre abonnement"; "customer_center_path_contact_support" = "Kontakt kundestøtte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index f1cfdf05e5..3115ea98f9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Aankopen herstellen"; -"customer_center_path_manage_subscription" = "Abonnement beheren"; +"customer_center_path_manage_subscription" = "Abonnement opzeggen"; "customer_center_path_refund" = "Terugbetaling aanvragen"; "customer_center_path_change_plan" = "Abonnement wijzigen"; "customer_center_path_contact_support" = "Contact opnemen met ondersteuning"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 6184a1d2a3..215e58d9dc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Gjenopprett kjøp"; -"customer_center_path_manage_subscription" = "Administrer abonnement"; +"customer_center_path_manage_subscription" = "Sei opp abonnement"; "customer_center_path_refund" = "Be om refusjon"; "customer_center_path_change_plan" = "Endre abonnement"; "customer_center_path_contact_support" = "Kontakt kundestøtte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 86db359348..784b38e891 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Przywróć zakupy"; -"customer_center_path_manage_subscription" = "Zarządzaj subskrypcją"; +"customer_center_path_manage_subscription" = "Anuluj subskrypcję"; "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ą"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index a958833137..89219171b9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurar compras"; -"customer_center_path_manage_subscription" = "Gerir subscrição"; +"customer_center_path_manage_subscription" = "Cancelar subscrição"; "customer_center_path_refund" = "Pedir reembolso"; "customer_center_path_change_plan" = "Alterar plano"; "customer_center_path_contact_support" = "Contactar suporte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 7a406ff429..8f6f624f9a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurar compras"; -"customer_center_path_manage_subscription" = "Gerir subscrição"; +"customer_center_path_manage_subscription" = "Cancelar assinatura"; "customer_center_path_refund" = "Pedir reembolso"; "customer_center_path_change_plan" = "Alterar plano"; "customer_center_path_contact_support" = "Contactar suporte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index a1e66a765f..a0bae52daf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurar compras"; -"customer_center_path_manage_subscription" = "Gerir subscrição"; +"customer_center_path_manage_subscription" = "Cancelar subscrição"; "customer_center_path_refund" = "Pedir reembolso"; "customer_center_path_change_plan" = "Alterar plano"; "customer_center_path_contact_support" = "Contactar suporte"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 885b905d77..2b5d27061a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Restaurați achizițiile"; -"customer_center_path_manage_subscription" = "Gestionați abonamentul"; +"customer_center_path_manage_subscription" = "Anulați abonamentul"; "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index d645a4ca35..cf54ebc4a1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Восстановить покупки"; -"customer_center_path_manage_subscription" = "Управление подпиской"; +"customer_center_path_manage_subscription" = "Отмена подписки"; "customer_center_path_refund" = "Запросить возврат средств"; "customer_center_path_change_plan" = "Изменить план"; "customer_center_path_contact_support" = "Связаться со службой поддержки"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index e8314e12ed..98c19b1c2b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Obnoviť nákupy"; -"customer_center_path_manage_subscription" = "Spravovať predplatné"; +"customer_center_path_manage_subscription" = "Zrušiť predplatné"; "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index b42089bde3..0cd5fb3e95 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Obnovi nakupe"; -"customer_center_path_manage_subscription" = "Upravljanje naročnine"; +"customer_center_path_manage_subscription" = "Preklic naročnine"; "customer_center_path_refund" = "Zahtevaj vračilo denarja"; "customer_center_path_change_plan" = "Spremeni paket"; "customer_center_path_contact_support" = "Obrni se na podporo"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index 46521bd908..fe430ae6ad 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Återställ köp"; -"customer_center_path_manage_subscription" = "Hantera prenumeration"; +"customer_center_path_manage_subscription" = "Avsluta prenumeration"; "customer_center_path_refund" = "Begär återbetalning"; "customer_center_path_change_plan" = "Byt plan"; "customer_center_path_contact_support" = "Kontakta support"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index bff1e1e64f..5644dc70e5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "กู้คืนการซื้อ"; -"customer_center_path_manage_subscription" = "จัดการการสมัครสมาชิก"; +"customer_center_path_manage_subscription" = "ยกเลิกการสมัครสมาชิก"; "customer_center_path_refund" = "ขอคืนเงิน"; "customer_center_path_change_plan" = "เปลี่ยนแผน"; "customer_center_path_contact_support" = "ติดต่อฝ่ายสนับสนุน"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index cd735b96b5..323e89eac5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Satın alımları geri yükle"; -"customer_center_path_manage_subscription" = "Aboneliği yönet"; +"customer_center_path_manage_subscription" = "Aboneliği iptal et"; "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ç"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 7d8355ac20..acd61226d8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Відновити покупки"; -"customer_center_path_manage_subscription" = "Керування підпискою"; +"customer_center_path_manage_subscription" = "Скасування підписки"; "customer_center_path_refund" = "Запросити повернення коштів"; "customer_center_path_change_plan" = "Змінити план"; "customer_center_path_contact_support" = "Зв'язатися зі службою підтримки"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 0fff98669b..37ab3c1e8f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "Khôi phục giao dịch mua"; -"customer_center_path_manage_subscription" = "Quản lý gói đăng ký"; +"customer_center_path_manage_subscription" = "Hủy gói đăng ký"; "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ợ"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index d78d342d08..91138ddce6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "恢复购买项目"; -"customer_center_path_manage_subscription" = "管理订阅"; +"customer_center_path_manage_subscription" = "取消订阅"; "customer_center_path_refund" = "申请退款"; "customer_center_path_change_plan" = "更改方案"; "customer_center_path_contact_support" = "联系支持人员"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index 926edd7bfe..ce8dd8fd5c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -47,7 +47,7 @@ /* Customer Center – paths */ "customer_center_path_restore" = "恢復購買項目"; -"customer_center_path_manage_subscription" = "管理訂閱"; +"customer_center_path_manage_subscription" = "取消訂閱"; "customer_center_path_refund" = "申請退款"; "customer_center_path_change_plan" = "變更方案"; "customer_center_path_contact_support" = "聯絡支援人員"; From 455841996d1cc55d3029da1d278d3ef6ab711512 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 13:29:05 -0500 Subject: [PATCH 24/42] fix(customer-center): stop the manage-subscriptions sheet being dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ManageSubscriptionsSheet modifier chose its branch on `groupId`, which is derived from viewModel.sheet and therefore turns non-nil in the same update that flips isPresented to true. SwiftUI treats the two branches as different view identities, so that update tore down the modifier that was about to present and built a different one — Apple's sheet never appeared. Reported from a device run: answering the cancellation survey dismissed the survey and returned to the Customer Center with nothing else shown. Branch on #available only, which is constant for the process, and pass the group id through as a value. The sheet is never presented while groupId is nil, so the empty-string fallback is unreachable in practice. Not coverable by the existing tests: the view model already asserts the state transition (sheet == .manageSubscriptions after the survey dismissal), and it still passes — the failure was entirely in the SwiftUI presentation layer, which the hostless test target cannot exercise. --- .../CustomerCenter/Views/CustomerCenterSheets.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index 842e7c570c..2092eebc95 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -99,8 +99,15 @@ private struct ManageSubscriptionsSheet: ViewModifier { let isPresented: Binding let groupId: String? func body(content: Content) -> some View { - if #available(iOS 17.0, *), let groupId { - content.manageSubscriptionsSheet(isPresented: isPresented, subscriptionGroupID: groupId) + // 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) } From 2ee92f99d062d8ab7c6b67a5c4ac6edc1daca89e Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 13:49:29 -0500 Subject: [PATCH 25/42] fix(customer-center): drop the disclosure chevron from action rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chevron promises a push onto the navigation stack. None of the action rows push: restore runs in place, cancel/change plan/refund/custom URL present sheets, and contact support leaves the app. The rows that genuinely push — "See all purchases" and the purchase detail rows — are NavigationLinks and draw their own chevron, so those are unaffected. The rows still read as tappable from the accent-coloured label, matching how action rows look elsewhere in iOS. The in-row progress indicator is kept. --- .../SuperwallKit/CustomerCenter/Views/PathsListView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift index 446d6e7187..0330ecd6c1 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -26,13 +26,15 @@ struct PathsListView: View { 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 — "See all purchases" and the purchase detail rows — are `NavigationLink`s and get + // their chevron from SwiftUI. HStack { Text(title(for: resolved.path)) Spacer() if loadingPathId == resolved.id { ProgressView() - } else { - Image(systemName: "chevron.right").foregroundStyle(.tertiary) } } } From 64735676f8af83bc763734679b498310db3133f4 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 14:38:35 -0500 Subject: [PATCH 26/42] fix(customer-center): stop the SDK restore alert doubling with the Customer Center's Restoring from the Customer Center with no purchases showed two stacked alerts: the SDK's paywall-worded restore-failure alert ("No Subscription Found") on top of the Customer Center's own result alert ("No past purchases", which is localized and offers Contact support). tryToRestore gains a presentsFailureAlert flag, defaulting to true so the public restorePurchases() and all paywall restores are unchanged. The Customer Center passes false and keeps presenting its own outcome. No automated coverage: the SDK presents that alert on the top-most view controller via the key window, which the hostless test target has no way to provide, so an assertion that no alert appears passes whether or not the fix works. Verified against the reported device repro instead. --- .../CustomerCenterDependencies.swift | 4 +++- .../Transactions/TransactionManager.swift | 19 ++++++++++++------- Sources/SuperwallKit/Superwall.swift | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 7438f7cd5b..58e0ea2376 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -118,7 +118,9 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { } @available(iOS 15.0, *) struct LiveRestorer: CustomerCenterRestoring { - func restorePurchases() async -> RestorationResult { await Superwall.shared.restorePurchases() } + func restorePurchases() async -> RestorationResult { + await Superwall.shared.restorePurchases(presentsFailureAlert: false) + } } struct LiveURLOpener: CustomerCenterURLOpening { var canOpenURLs: Bool { UIApplication.sharedApplication != nil } 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.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 } From 6a1e1c93f3345eadd77400917cce600ce9d2dd16 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 14:47:52 -0500 Subject: [PATCH 27/42] fix(customer-center): animate the update banner's dismissal Tapping Continue flipped the flag outside a transaction, so the banner's section vanished from the list in a single frame. Wrap the change in withAnimation at the view layer, so removing the section from the list is part of the same transaction. Reduce Motion gets withAnimation(nil), which applies the change without animating. Also adds a round-trip test for the appearance accent: a UIColor passed to ColorPair is stored as hex and has to parse back into a Color for the theme to tint anything. Nothing covered that path before. --- .../Views/AppUpdateWarningView.swift | 14 ++++++-- SuperwallKit.xcodeproj/project.pbxproj | 4 +++ .../Views/AccentColorRoundTripTests.swift | 32 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/AccentColorRoundTripTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift index 6a90f46ba7..23c0c61c19 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/AppUpdateWarningView.swift @@ -12,6 +12,7 @@ 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 { @@ -24,9 +25,16 @@ struct AppUpdateWarningView: View { .buttonStyle(.borderedProminent) .accessibilityIdentifier("customer_center.update") } - Button(strings.string("customer_center_update_continue")) { viewModel.continueAfterUpdateWarning() } - .buttonStyle(.bordered) - .accessibilityIdentifier("customer_center.update_continue") + 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index abd16c53be..2038f26a2d 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -437,6 +437,7 @@ 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 */; }; @@ -1267,6 +1268,7 @@ 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 = ""; }; @@ -1366,6 +1368,7 @@ 0885E36F54C6369D2E5FCDC7 /* Views */ = { isa = PBXGroup; children = ( + F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */, 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, ); path = Views; @@ -3528,6 +3531,7 @@ 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 */, 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") + } +} From 12155dcf7058297b99f20962ec8ed8edf9b9086f Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 14:57:23 -0500 Subject: [PATCH 28/42] fix(customer-center): fire didDismiss from a visibility count, not the root view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root view's `.onDisappear` fired `dismiss()` directly, gated by an `isNavigatingWithinCustomerCenter` flag set/cleared by pushed screens' onAppear/onDisappear. In embedded mode (`usesExistingNavigation`) the host owns the navigation stack, so if it tears its stack down while a pushed screen (purchase detail / purchase history) is on top — popping to root, resetting a NavigationPath, or a long-press-Back past the Customer Center — the root view never reappears and the flag never clears. `didDismiss` and `customerCenterClose` then never fire at all. The flag was also inaccurate two pushes deep: history → purchase detail cleared it while still inside. Replaced the boolean with a visibility count on the view model: `surfaceDidAppear()`/`surfaceDidDisappear()` increment/decrement a counter, attached to every surface that can be on screen (root, purchase detail screen, purchase history, purchase detail rows — not sheets, since those present over a root that stays alive). When the count reaches zero it debounces briefly (default 0.3s, cancellable) before calling `dismiss()`, because a push/pop transition can briefly have both or neither surface on screen — one runloop turn isn't enough to tell "navigating within the Customer Center" from "actually gone". `dismiss()` keeps its `didDismiss` latch, so double-firing stays impossible regardless of how many surfaces disappear. Sheet mode and the UIKit CustomerCenterViewController are unaffected: the root view still appears/disappears exactly once for those, so `didDismiss` still fires exactly once. Co-Authored-By: Claude Sonnet 5 --- .../ViewModel/CustomerCenterViewModel.swift | 75 +++++++++++----- .../Views/CustomerCenterView.swift | 7 +- .../Views/ManagementScreenView.swift | 4 +- .../Views/PurchaseHistoryView.swift | 9 +- .../CustomerCenterViewModelTests.swift | 88 ++++++++++++++++--- 5 files changed, 142 insertions(+), 41 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 8a7017535b..c19b3e0982 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -31,12 +31,8 @@ final class CustomerCenterViewModel: ObservableObject { var presentationMode = "sheet" private(set) var pendingSurvey: PendingSurvey? - /// `true` while a screen the Customer Center pushed itself (purchase detail, purchase - /// history) covers the root view. In embedded mode (`usesExistingNavigation`) such a push - /// removes the root view from the hierarchy, which must not count as a dismissal. - var isNavigatingWithinCustomerCenter = false - private let dependencies: CustomerCenterDependencies + private let dismissDebounceInterval: TimeInterval private let isChangePlanSheetAvailable: Bool private var products: [String: ProductDisplayInfo] = [:] private var familyShared: Set = [] @@ -53,15 +49,23 @@ final class CustomerCenterViewModel: ObservableObject { private var didDismiss = false 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? + init( configuration: CustomerCenterConfiguration, dependencies: CustomerCenterDependencies, strings: CustomerCenterStrings, - isChangePlanSheetAvailable: Bool? = nil + isChangePlanSheetAvailable: Bool? = nil, + dismissDebounceInterval: TimeInterval = 0.3 ) { self.configuration = configuration self.dependencies = dependencies self.strings = strings + self.dismissDebounceInterval = dismissDebounceInterval if let isChangePlanSheetAvailable { self.isChangePlanSheetAvailable = isChangePlanSheetAvailable } else if #available(iOS 17.0, *) { @@ -289,21 +293,6 @@ final class CustomerCenterViewModel: ObservableObject { showsUpdateBanner = false } - /// Call from the root view's `onDisappear`. In embedded mode a push within the Customer - /// Center (purchase detail / purchase history) also removes the root view from the - /// hierarchy, which must not count as a dismissal. - func rootViewDidDisappear() { - guard !isNavigatingWithinCustomerCenter else { return } - dismiss() - } - - func dismiss() { - guard !didDismiss else { return } - didDismiss = true - callbacks.didDismiss?() - Task { await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterClose()) } - } - // swiftlint:disable:next large_tuple func historySections() -> ( active: [PurchasePresentation], @@ -315,6 +304,50 @@ final class CustomerCenterViewModel: ObservableObject { } } +// 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 (purchase detail, purchase history, purchase detail rows). In embedded mode + /// (`usesExistingNavigation`) the host owns the navigation stack, so pushing one of these + /// screens removes the previous surface from the hierarchy without the Customer Center + /// actually closing. Counting concurrently visible surfaces (instead of a single boolean) + /// correctly tracks nested pushes, and cancels any pending dismissal from a prior disappear. + func surfaceDidAppear() { + visibleSurfaceCount += 1 + dismissDebounceTask?.cancel() + dismissDebounceTask = nil + } + + /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear()``. + /// When the count drops to zero, waits a short debounce before dismissing — a push/pop + /// transition can briefly have both the old and new surface on screen, or neither, so a + /// single runloop turn isn't enough to distinguish "navigating within the Customer Center" + /// from "the Customer Center was torn down". If another surface appears before the debounce + /// elapses, ``surfaceDidAppear()`` cancels it and no dismissal happens. + func surfaceDidDisappear() { + visibleSurfaceCount = max(0, visibleSurfaceCount - 1) + guard visibleSurfaceCount == 0 else { return } + dismissDebounceTask?.cancel() + dismissDebounceTask = Task { [weak self, dismissDebounceInterval] in + try? await Task.sleep(nanoseconds: UInt64(dismissDebounceInterval * 1_000_000_000)) + guard !Task.isCancelled else { return } + guard let self, self.visibleSurfaceCount == 0 else { return } + self.dismiss() + } + } + + func dismiss() { + guard !didDismiss else { return } + didDismiss = true + dismissDebounceTask?.cancel() + dismissDebounceTask = nil + callbacks.didDismiss?() + Task { await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterClose()) } + } +} + // MARK: - Support email @available(iOS 15.0, *) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index a5a99e077c..310383f7b4 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -94,9 +94,10 @@ public struct CustomerCenterView: View { viewModel.callbacks = Self.merged(viewModel.callbacks, callbacksBox.callbacks) await viewModel.load() } - // `rootViewDidDisappear` skips the dismissal when a screen the Customer Center pushed - // itself (detail / history) covers the root view in embedded mode. - .onDisappear { viewModel.rootViewDidDisappear() } + // 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 diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 569ea2da02..1ac3813983 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -86,7 +86,7 @@ struct PurchaseDetailScreenView: View { .listStyle(.insetGrouped) .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.isNavigatingWithinCustomerCenter = true } - .onDisappear { viewModel.isNavigatingWithinCustomerCenter = false } + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index a64cc22cbb..47ab28c39a 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -22,8 +22,8 @@ struct PurchaseHistoryView: View { .listStyle(.insetGrouped) .navigationTitle(strings.string("customer_center_purchase_history")) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.isNavigatingWithinCustomerCenter = true } - .onDisappear { viewModel.isNavigatingWithinCustomerCenter = false } + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } @ViewBuilder @@ -32,7 +32,7 @@ struct PurchaseHistoryView: View { Section(strings.string(key)) { ForEach(items) { item in NavigationLink { - PurchaseDetailRows(purchase: item) + PurchaseDetailRows(viewModel: viewModel, purchase: item) } label: { PurchaseCardView(purchase: item, refundResult: nil) } @@ -44,6 +44,7 @@ struct PurchaseHistoryView: View { @available(iOS 15.0, *) struct PurchaseDetailRows: View { + @ObservedObject var viewModel: CustomerCenterViewModel let purchase: PurchasePresentation @Environment(\.customerCenterStrings) private var strings private let dateFormatter: DateFormatter = { @@ -80,6 +81,8 @@ struct PurchaseDetailRows: View { } .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } private func row(_ label: String, _ value: String) -> some View { diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index 4b62e8cfde..90fc215a6a 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -313,28 +313,92 @@ struct CustomerCenterViewModelTests { #expect(infoMock.didRefreshReceipts) } - // MARK: - Embedded navigation + // MARK: - Embedded navigation (visibility count) + + /// Tests below use a short debounce so they don't need real sleeps of `dismissDebounceInterval` + /// (the production default, 0.3s) 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 + ) + } - @Test("navigating within the Customer Center is not treated as a dismissal") - func navigationWithinCustomerCenterIsNotDismissal() async { - let (vm, _, _) = make(info: info([sub()])) + @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() + try? await Task.sleep(nanoseconds: 100_000_000) + #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 } - // Embedded mode: pushing the detail/history screen removes the root view from the hierarchy. - vm.isNavigatingWithinCustomerCenter = true - vm.rootViewDidDisappear() - try? await Task.sleep(nanoseconds: 50_000_000) + // 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) - // A real disappearance still dismisses. - vm.isNavigatingWithinCustomerCenter = false - vm.rootViewDidDisappear() - try? await Task.sleep(nanoseconds: 50_000_000) + // The second surface disappearing too means the Customer Center is genuinely gone. + vm.surfaceDidDisappear() + try? await Task.sleep(nanoseconds: 100_000_000) #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() + try? await Task.sleep(nanoseconds: 100_000_000) + #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() + try? await Task.sleep(nanoseconds: 100_000_000) + #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() From 2001d653c712dd582ca5841953e909aa80638bef Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 21 Aug 2026 15:03:33 -0500 Subject: [PATCH 29/42] fix(customer-center): widen the dismissal debounce past a nav transition Review flagged 0.3s as uncomfortably close to a UINavigationController push/pop (~0.35s). During a pop the outgoing screen's onDisappear can land before the root's onAppear, dipping the visible-surface count to zero mid-transition; if the debounce elapses in that window, didDismiss fires while the user is still inside the Customer Center. 0.6s clears it with margin. The interval only delays how soon didDismiss reaches the host, and nothing is gated on it. Tests inject a short interval, so they are unaffected. --- .../CustomerCenter/ViewModel/CustomerCenterViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index c19b3e0982..87f1a7bbc1 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -60,7 +60,11 @@ final class CustomerCenterViewModel: ObservableObject { dependencies: CustomerCenterDependencies, strings: CustomerCenterStrings, isChangePlanSheetAvailable: Bool? = nil, - dismissDebounceInterval: TimeInterval = 0.3 + // 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 From 8c763af711e5fc4e7dce2111d200675c6b3f104d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:00:15 +0200 Subject: [PATCH 30/42] chore(release): bump version to 4.17.0 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd00db5a1a..8ca1426b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.16.4 +## 4.17.0 ### Enhancements 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/SuperwallKit.podspec b/SuperwallKit.podspec index b031453c92..39380da9e4 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" From 97702aae815b76f5a724a83b34042a39582622f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:35 +0200 Subject: [PATCH 31/42] =?UTF-8?q?fix(customer-center):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20dismissal=20delivery,=20locale,=20diagnostics,=20ha?= =?UTF-8?q?shes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - viewDidDisappear fires the view model's dismissal before onDismiss releases the retained delegate, and the dismissal debounce captures the model strongly so SwiftUI sheet teardown can't drop didDismiss or the close event - date formatters follow the SDK's preferred locale, not the system's - support email diagnostics list active entitlement ids, not product ids - Support, Appearance and ColorPair hash by value, matching isEqual Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++ .../Logic/PurchasePresentationBuilder.swift | 26 +++++-- .../Models/CustomerCenterConfiguration.swift | 28 ++++++++ .../UIKit/CustomerCenterViewController.swift | 6 ++ .../ViewModel/CustomerCenterViewModel.swift | 22 ++++-- .../Views/PurchaseHistoryView.swift | 12 +++- .../CustomerCenterManagerTests.swift | 29 ++++++++ .../PurchasePresentationBuilderTests.swift | 14 ++++ .../CustomerCenterConfigurationTests.swift | 18 +++++ .../CustomerCenterViewModelTests.swift | 70 +++++++++++++++---- 10 files changed, 202 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca1426b06..2e7dd0b7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Fixes +- Fixes the Customer Center's dismissal callback and close event sometimes not firing. +- Formats Customer Center dates using the locale set in the SDK options instead of the device locale. +- Lists active entitlements instead of product identifiers in the Customer Center support email. +- Fixes equal Customer Center configurations hashing differently. - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. - Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift index 8a2b7bd1bc..d741937092 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -11,12 +11,26 @@ import Foundation struct PurchasePresentationBuilder { var now: () -> Date = Date.init var strings: CustomerCenterStrings - var dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - return formatter - }() + 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 (`SuperwallOptions.localeIdentifier` 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 subs = subscriptionPresentations(customerInfo.subscriptions, products: products) diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index 73e732b964..5f92ad14d7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -8,6 +8,8 @@ import Foundation import UIKit +// swiftlint:disable type_body_length + /// 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 @@ -282,6 +284,15 @@ public final class CustomerCenterConfiguration: NSObject, Codable { && shouldWarnToUpdate == other.shouldWarnToUpdate && webManagementURL == other.webManagementURL } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(email) + hasher.combine(latestAppVersion) + hasher.combine(shouldWarnToUpdate) + hasher.combine(webManagementURL) + return hasher.finalize() + } } // MARK: - Appearance @@ -315,6 +326,16 @@ public final class CustomerCenterConfiguration: NSObject, Codable { && buttonText == other.buttonText && buttonBackground == other.buttonBackground } + override public var hash: Int { + var hasher = Hasher() + hasher.combine(accent) + hasher.combine(background) + hasher.combine(text) + hasher.combine(buttonText) + hasher.combine(buttonBackground) + return hasher.finalize() + } + /// A light/dark color pair stored as hex strings (`#RRGGBB` or `#RRGGBBAA`). @objc(SWKCustomerCenterColorPair) @objcMembers @@ -335,6 +356,13 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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() + } } } } diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 84dbab2d70..3887a5608d 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -83,6 +83,12 @@ public final class CustomerCenterViewController: UIViewController { override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if isBeingDismissed || presentingViewController == nil { + // A dismissed view controller 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. `dismiss()` is idempotent, so the + // debounce firing later (or having fired) is harmless. + viewModel.dismiss() onDismiss?() } } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 87f1a7bbc1..a369eb5072 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -31,6 +31,10 @@ final class CustomerCenterViewModel: ObservableObject { var presentationMode = "sheet" private(set) var pendingSurvey: PendingSurvey? + /// Locale for date formatting, matching the locale the localized strings resolve against + /// (`SuperwallOptions.localeIdentifier` when set) rather than the system locale. + var locale: Locale { dependencies.environment.locale } + private let dependencies: CustomerCenterDependencies private let dismissDebounceInterval: TimeInterval private let isChangePlanSheetAvailable: Bool @@ -47,6 +51,8 @@ final class CustomerCenterViewModel: ObservableObject { private var updateWarningDismissed = false private var hasTrackedOpen = false private var didDismiss = false + /// Active entitlement identifiers from the latest `CustomerInfo`, for support diagnostics. + private var activeEntitlementIds: [String] = [] private var cancellables = Set() /// Number of Customer Center surfaces (root + any pushed screens) currently on screen. @@ -114,8 +120,9 @@ final class CustomerCenterViewModel: ObservableObject { } familyShared = shared } - let builder = PurchasePresentationBuilder(strings: strings) + 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 showsUpdateBanner = !updateWarningDismissed && configuration.support.shouldWarnToUpdate @@ -334,11 +341,15 @@ extension CustomerCenterViewModel { visibleSurfaceCount = max(0, visibleSurfaceCount - 1) guard visibleSurfaceCount == 0 else { return } dismissDebounceTask?.cancel() - dismissDebounceTask = Task { [weak self, dismissDebounceInterval] in + // 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 let self, self.visibleSurfaceCount == 0 else { return } - self.dismiss() + guard visibleSurfaceCount == 0 else { return } + dismiss() } } @@ -367,14 +378,13 @@ extension CustomerCenterViewModel { private var diagnostics: SupportEmailDiagnostics { let env = dependencies.environment - let active = purchases.filter(\.isActive).compactMap(\.productId) return .init( userId: env.userId, appVersion: env.appVersion, osVersion: env.osVersion, deviceModel: env.deviceModel, sdkVersion: env.sdkVersion, - activeEntitlementIds: active, + activeEntitlementIds: activeEntitlementIds, isSandbox: env.isSandbox ) } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index 47ab28c39a..ce0c5907b6 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -47,12 +47,18 @@ struct PurchaseDetailRows: View { @ObservedObject var viewModel: CustomerCenterViewModel let purchase: PurchasePresentation @Environment(\.customerCenterStrings) private var strings - private let dateFormatter: DateFormatter = { + private let dateFormatter: DateFormatter + + init(viewModel: CustomerCenterViewModel, purchase: PurchasePresentation) { + self.viewModel = viewModel + self.purchase = purchase + // Dates must follow the same locale as the localized strings, not the system locale. let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short - return formatter - }() + formatter.locale = viewModel.locale + dateFormatter = formatter + } var body: some View { List { diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift index 78d2e50754..eafa7e908f 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift @@ -87,6 +87,35 @@ struct CustomerCenterManagerTests { 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) + // 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 } + + // Hostless: the controller isn't in a window, so `presentingViewController == nil` takes the + // same teardown branch a real dismissal would. + 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 diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index ecbdb8de33..3624dc84e3 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -62,6 +62,20 @@ struct PurchasePresentationBuilderTests { 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]) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift index e6ca05c590..b88e39be70 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift @@ -43,6 +43,24 @@ struct CustomerCenterConfigurationTests { #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() diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index 90fc215a6a..cecc108a8b 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -57,6 +57,27 @@ struct CustomerCenterViewModelTests { #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 @@ -328,6 +349,17 @@ struct CustomerCenterViewModelTests { ) } + /// 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()])) @@ -337,7 +369,7 @@ struct CustomerCenterViewModelTests { vm.surfaceDidAppear() vm.surfaceDidDisappear() - try? await Task.sleep(nanoseconds: 100_000_000) + await waitUntil { dismissCount == 1 } #expect(dismissCount == 1) } @@ -358,7 +390,7 @@ struct CustomerCenterViewModelTests { // The second surface disappearing too means the Customer Center is genuinely gone. vm.surfaceDidDisappear() - try? await Task.sleep(nanoseconds: 100_000_000) + await waitUntil { dismissed } #expect(dismissed) } @@ -375,7 +407,23 @@ struct CustomerCenterViewModelTests { vm.surfaceDidDisappear() vm.surfaceDidDisappear() vm.surfaceDidDisappear() - try? await Task.sleep(nanoseconds: 100_000_000) + 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) } @@ -388,7 +436,7 @@ struct CustomerCenterViewModelTests { vm.surfaceDidAppear() vm.surfaceDidDisappear() - try? await Task.sleep(nanoseconds: 100_000_000) + await waitUntil { dismissCount == 1 } #expect(dismissCount == 1) // A stray extra disappear (or a direct call) after the debounce already fired must not @@ -406,19 +454,17 @@ struct CustomerCenterViewModelTests { await vm.load() #expect(vm.state == .noPurchases) infoMock.subject.value = info([sub()]) - try? await Task.sleep(nanoseconds: 100_000_000) + await waitUntil { vm.state == .management } #expect(vm.state == .management) var dismissed = false vm.callbacks.didDismiss = { dismissed = true } vm.dismiss() - try? await Task.sleep(nanoseconds: 50_000_000) #expect(dismissed) - let hasCloseEvent: Bool - if case .customerCenterClose = tracker.events.last { - hasCloseEvent = true - } else { - hasCloseEvent = false + func trackedClose() -> Bool { + if case .customerCenterClose = tracker.events.last { return true } + return false } - #expect(hasCloseEvent) + await waitUntil { trackedClose() } + #expect(trackedClose()) } } From 7ab4a98b377a9bf4911a47454e752ad6e4050483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:39:35 +0200 Subject: [PATCH 32/42] test: run the suite in parallel with per-instance cache isolation parallelizable: true now lives in project.yml so regeneration keeps it. Defaulted Cache instances get a unique on-disk namespace under the test runner so concurrently running tests stop contaminating each other's storage; timing-sensitive tests poll for outcomes instead of assuming a fixed sleep beats main-actor congestion. Co-Authored-By: Claude Fable 5 --- .../SuperwallKit/Storage/Cache/Cache.swift | 23 +++++++++++++++---- Sources/SuperwallKit/Storage/Storage.swift | 2 +- .../xcschemes/SuperwallKit.xcscheme | 2 +- .../Storage/StorageMock.swift | 2 +- .../Web/WebEntitlementRedeemerTests.swift | 13 ++++++----- project.yml | 1 + 6 files changed, 30 insertions(+), 13 deletions(-) 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/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index 8c5e0a1832..1fd7d5a1ab 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -41,7 +41,7 @@ + parallelizable = "YES"> ? = [], - 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..1a1b38ecf8 100644 --- a/project.yml +++ b/project.yml @@ -12,6 +12,7 @@ targets: scheme: testTargets: - name: SuperwallKitTests + parallelizable: true commandLineArguments: SUPERWALL_UNIT_TESTS: true settings: From 712eee1ec5031adcd9f853e2b653004027151654 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Tue, 25 Aug 2026 14:06:48 -0500 Subject: [PATCH 33/42] fix(customer-center): stop hiding purchases the user cannot reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management screen collapsed non-subscription purchases to the first two. That is fine while "See all purchases" is there to show the rest, but `showsPurchaseHistory` can switch that row off — and then anything past the cap was simply unreachable. Only collapse when the full list is still one tap away. Also corrects a test comment that still named the old 0.3s dismissal debounce; the default has been 0.6s since it was widened past a navigation transition. Co-Authored-By: Claude Opus 5 --- .../Views/ManagementScreenView.swift | 12 ++- SuperwallKit.podspec | 2 +- SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../CustomerCenterViewModelTests.swift | 2 +- .../Views/ManagementScreenViewTests.swift | 77 +++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 1ac3813983..e978c605f7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -41,7 +41,7 @@ struct ManagementScreenView: View { } if !others.isEmpty { Section(strings.string("customer_center_section_purchases")) { - ForEach(others.prefix(2)) { PurchaseCardView(purchase: $0, refundResult: nil) } + ForEach(visibleOthers) { PurchaseCardView(purchase: $0, refundResult: nil) } } } Section(strings.string("customer_center_section_actions")) { @@ -64,6 +64,16 @@ struct ManagementScreenView: View { .navigationBarTitleDisplayMode(.inline) } + /// Non-subscription purchases to show inline. Collapsing to the first few keeps the management + /// screen scannable, but that's only acceptable while the rest stay reachable — with + /// `showsPurchaseHistory` off there is no "See all purchases" row, so a cap would make anything + /// past it unreachable rather than merely collapsed. + var visibleOthers: [PurchasePresentation] { + viewModel.configuration.showsPurchaseHistory ? Array(others.prefix(Self.inlineOthersLimit)) : others + } + + private static let inlineOthersLimit = 2 + private var navigationTitle: String { viewModel.configuration.managementScreen.title ?? strings.string("customer_center_management_title") } diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 39380da9e4..a7fa74956a 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.17.0" + 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 2038f26a2d..1f73afdd9e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -42,6 +42,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 */; }; + 0F632AEB4FDA9D90CFCBD1F7 /* ManagementScreenViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.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 */; }; @@ -432,6 +433,7 @@ 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 */; }; @@ -946,6 +948,7 @@ 7C468F707B216A2F20C6092D /* MMPInstallAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MMPInstallAttributionTests.swift; sourceTree = ""; }; 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterDelegate.swift; sourceTree = ""; }; 7E27997BBCEAC330E4FB3718 /* pt_BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_BR; path = pt_BR.lproj/Localizable.strings; sourceTree = ""; }; + 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagementScreenViewTests.swift; 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 = ""; }; 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogic.swift; sourceTree = ""; }; @@ -1262,6 +1265,7 @@ 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 = ""; }; @@ -1370,6 +1374,7 @@ children = ( F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */, 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, + 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */, ); path = Views; sourceTree = ""; @@ -3369,6 +3374,7 @@ isa = PBXGroup; children = ( A69194A3AABBE56CB18177F1 /* CustomerCenterDelegateAdapterTests.swift */, + F30029EE3419EE5BD4CD2948 /* CustomerCenterViewControllerTests.swift */, ); path = UIKit; sourceTree = ""; @@ -3570,6 +3576,7 @@ 26081D80FCF7BCD475103467 /* CustomerCenterManagerTests.swift in Sources */, F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.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 */, @@ -3603,6 +3610,7 @@ 4DE01655FC4CC148DD3D161C /* LoggerMock.swift in Sources */, 556DDBA011967A3F2411AAE7 /* MMPInstallAttributionTests.swift in Sources */, 6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */, + 0F632AEB4FDA9D90CFCBD1F7 /* ManagementScreenViewTests.swift in Sources */, A9B924A1211117378743A534 /* MicrophonePermissionTests.swift in Sources */, B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */, BA957415E2E1A38A25550B99 /* MockIntroductoryPeriod.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index cecc108a8b..82595f7a4e 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -337,7 +337,7 @@ struct CustomerCenterViewModelTests { // MARK: - Embedded navigation (visibility count) /// Tests below use a short debounce so they don't need real sleeps of `dismissDebounceInterval` - /// (the production default, 0.3s) to observe whether `dismiss()` fired. + /// (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( diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift new file mode 100644 index 0000000000..3f20318aad --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift @@ -0,0 +1,77 @@ +// +// ManagementScreenViewTests.swift +// +// +// Created by Jordan Morgan on 25/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("ManagementScreenView inline purchases") +@MainActor +struct ManagementScreenViewTests { + @available(iOS 15.0, *) + private func makeViewModel( + nonSubscriptionCount: Int, + showsPurchaseHistory: Bool + ) async -> CustomerCenterViewModel { + let now = Date() + let purchases = (0.. Date: Tue, 25 Aug 2026 14:06:58 -0500 Subject: [PATCH 34/42] feat(customer-center): support pushing onto a navigation stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CustomerCenterViewController` was modal-only by construction: a hardcoded `.pageSheet` style, its own `NavigationView`, and a close button wired to `dismiss(animated:)` that does nothing to a pushed controller. Its teardown check was modal-only too — bare `presentingViewController == nil` is true for a pushed controller's entire lifetime, so every cover event read as a dismissal, and because `dismiss()` latches, the real teardown then went silent. Adds `CustomerCenterPresentationStyle`. `.pushed` shows a back button instead of a close button and hides the host's navigation bar while on screen, handing it back exactly as it was found. The Customer Center keeps supplying its own bar in both styles because its drill-downs are SwiftUI `NavigationLink`s, which do nothing without a SwiftUI navigation ancestor — a surrounding `UINavigationController` is not one. Swipe-to-go-back is driven by a private gesture delegate, kept off the view controller so the conformance doesn't land on the SDK's public surface. The controller is now a `UIHostingController` subclass rather than a plain controller wrapping a child host, so SwiftUI's `.navigationTitle` reaches the host's bar instead of stopping at an intermediate controller. Teardown is now a walk up the parent chain for `isBeingDismissed`/`isMovingFromParent`, which also catches a container the host tears down, plus a recorded `wasPresentedModally` paired with `presentingViewController` to keep the modal path sound. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../CustomerCenterManager.swift | 2 +- .../UIKit/CustomerCenterViewController.swift | 203 +++++++++++++--- .../Views/CustomerCenterStrings+English.swift | 1 + .../Views/CustomerCenterView.swift | 44 +++- .../Documentation.docc/CustomerCenter.md | 19 +- .../ar.lproj/Localizable.strings | 1 + .../ca.lproj/Localizable.strings | 1 + .../cs.lproj/Localizable.strings | 1 + .../da.lproj/Localizable.strings | 1 + .../de.lproj/Localizable.strings | 1 + .../el.lproj/Localizable.strings | 1 + .../en.lproj/Localizable.strings | 1 + .../en_AU.lproj/Localizable.strings | 1 + .../en_GB.lproj/Localizable.strings | 1 + .../es.lproj/Localizable.strings | 1 + .../es_419.lproj/Localizable.strings | 1 + .../fi.lproj/Localizable.strings | 1 + .../fr.lproj/Localizable.strings | 1 + .../fr_CA.lproj/Localizable.strings | 1 + .../he.lproj/Localizable.strings | 1 + .../hi.lproj/Localizable.strings | 1 + .../hr.lproj/Localizable.strings | 1 + .../hu.lproj/Localizable.strings | 1 + .../id.lproj/Localizable.strings | 1 + .../it.lproj/Localizable.strings | 1 + .../ja.lproj/Localizable.strings | 1 + .../ko.lproj/Localizable.strings | 1 + .../ms.lproj/Localizable.strings | 1 + .../nb.lproj/Localizable.strings | 1 + .../nl.lproj/Localizable.strings | 1 + .../nn.lproj/Localizable.strings | 1 + .../pl.lproj/Localizable.strings | 1 + .../pt.lproj/Localizable.strings | 1 + .../pt_BR.lproj/Localizable.strings | 1 + .../pt_PT.lproj/Localizable.strings | 1 + .../ro.lproj/Localizable.strings | 1 + .../ru.lproj/Localizable.strings | 1 + .../sk.lproj/Localizable.strings | 1 + .../sl.lproj/Localizable.strings | 1 + .../sv.lproj/Localizable.strings | 1 + .../th.lproj/Localizable.strings | 1 + .../tr.lproj/Localizable.strings | 1 + .../uk.lproj/Localizable.strings | 1 + .../vi.lproj/Localizable.strings | 1 + .../zh_Hans.lproj/Localizable.strings | 1 + .../zh_Hant.lproj/Localizable.strings | 1 + .../CustomerCenterManagerTests.swift | 12 +- .../CustomerCenterViewControllerTests.swift | 219 ++++++++++++++++++ 49 files changed, 496 insertions(+), 46 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7dd0b7ec..6b7f8c72e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). Requires iOS 15+. - Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. +- `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. ### Fixes diff --git a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift index 8875c77bc4..c34eae96fe 100644 --- a/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift +++ b/Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift @@ -132,7 +132,7 @@ final class CustomerCenterManager { dependencies: .live(container: container, configuration: resolved), strings: .bundled() ) - let controller = CustomerCenterViewController(viewModel: viewModel, adapter: adapter) + let controller = CustomerCenterViewController(viewModel: viewModel, adapter: adapter, presentationStyle: .modal) controller.onDismiss = { [weak self] in self?.presentedController = nil self?.retainedDelegate = nil diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 3887a5608d..5761eab51c 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -8,88 +8,217 @@ 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. Shows a back button that pops it off your + /// stack, and hides your navigation bar for as long as it is on screen so that only one + /// navigation bar is ever visible. + 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: UIViewController { +public final class CustomerCenterViewController: UIHostingController { let viewModel: CustomerCenterViewModel - private var hosting: UIHostingController? + let presentationStyle: CustomerCenterPresentationStyle var onDismiss: (() -> Void)? + /// The host navigation bar's visibility before ``CustomerCenterPresentationStyle/pushed`` hid + /// it, so it can be handed back exactly as it was found. + private var hostNavigationBarWasHidden: Bool? + private var replacedInteractivePopDelegate: UIGestureRecognizerDelegate? + private lazy var interactivePopDelegate = InteractivePopGestureDelegate() + + /// 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 + /// - 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) + 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:delegate:) - public convenience init(configuration: CustomerCenterConfiguration?, objcDelegate: CustomerCenterDelegateObjc?) { + @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) + adapter: CustomerCenterDelegateAdapter(swiftDelegate: nil, objcDelegate: objcDelegate), + presentationStyle: presentationStyle ) } - init(viewModel: CustomerCenterViewModel, adapter: CustomerCenterDelegateAdapter) { + init( + viewModel: CustomerCenterViewModel, + adapter: CustomerCenterDelegateAdapter, + presentationStyle: CustomerCenterPresentationStyle + ) { self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) + self.presentationStyle = presentationStyle viewModel.callbacks = adapter.makeCallbacks() - modalPresentationStyle = .pageSheet + viewModel.presentationMode = presentationStyle.analyticsValue + + // Both styles keep the Customer Center's own navigation stack, hence + // `usesExistingNavigation: false` even when pushed. Its drill-downs — purchase history and + // per-purchase detail — are SwiftUI `NavigationLink`s, and a `NavigationLink` does nothing + // without a SwiftUI navigation ancestor; a surrounding `UINavigationController` is not one. + // `.pushed` hides the host's bar instead (see `viewWillAppear`), so the user still only ever + // sees a single navigation bar. + var options = CustomerCenterNavigationOptions( + usesExistingNavigation: false, + showsCloseButton: presentationStyle == .modal, + showsBackButton: presentationStyle == .pushed + ) + 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) } + options.onBack = { [weak self] in self?.navigationController?.popViewController(animated: true) } + rootView = CustomerCenterView(viewModel: viewModel, navigationOptions: options) + + if presentationStyle == .modal { + modalPresentationStyle = .pageSheet + } } @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) is not supported") } - override public func viewDidLoad() { - super.viewDidLoad() - let options = CustomerCenterNavigationOptions( - usesExistingNavigation: false, - showsCloseButton: true - ) { [weak self] in - self?.dismiss(animated: true) + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + guard presentationStyle == .pushed, let navigationController else { return } + if hostNavigationBarWasHidden == nil { + hostNavigationBarWasHidden = navigationController.isNavigationBarHidden + } + navigationController.setNavigationBarHidden(true, animated: animated) + + // Hiding the bar also takes UIKit's swipe-to-go-back with it, so drive the recognizer for as + // long as we're on screen and hand it back untouched on the way out. + replacedInteractivePopDelegate = navigationController.interactivePopGestureRecognizer?.delegate + interactivePopDelegate.navigationController = navigationController + navigationController.interactivePopGestureRecognizer?.delegate = interactivePopDelegate + navigationController.interactivePopGestureRecognizer?.isEnabled = true + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + wasPresentedModally = presentingViewController != nil + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + guard presentationStyle == .pushed, let navigationController else { return } + // Also runs when the host merely covers us — pushing its own screen on top, or presenting + // something. Restoring the bar is right in that case too: the screen taking over wants its + // own chrome, and `viewWillAppear` hides it again if we come back. + if let hostNavigationBarWasHidden { + navigationController.setNavigationBarHidden(hostNavigationBarWasHidden, animated: animated) } - let host = UIHostingController(rootView: CustomerCenterView(viewModel: viewModel, navigationOptions: options)) - addChild(host) - view.addSubview(host.view) - host.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - host.didMove(toParent: self) - hosting = host + hostNavigationBarWasHidden = nil + navigationController.interactivePopGestureRecognizer?.delegate = replacedInteractivePopDelegate + replacedInteractivePopDelegate = nil } override public func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - if isBeingDismissed || presentingViewController == nil { - // A dismissed view controller 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. `dismiss()` is idempotent, so the - // debounce firing later (or having fired) is harmless. - viewModel.dismiss() - onDismiss?() + guard isLeavingHierarchy else { return } + // 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. `dismiss()` is idempotent, so the + // debounce firing later (or having fired) is harmless. + 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 { + var controller: UIViewController? = self + while let current = controller { + if current.isBeingDismissed || current.isMovingFromParent { + return true + } + controller = current.parent } + return wasPresentedModally && presentingViewController == nil + } +} + +/// Keeps swipe-to-go-back working while ``CustomerCenterPresentationStyle/pushed`` has the host's +/// navigation bar hidden. Deliberately not a conformance on `CustomerCenterViewController` itself, +/// which would put `gestureRecognizerShouldBegin(_:)` on the SDK's public surface. +@available(iOS 15.0, *) +private final class InteractivePopGestureDelegate: NSObject, UIGestureRecognizerDelegate { + weak var navigationController: UINavigationController? + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + // Swiping on the stack's root would leave UIKit mid-transition with nothing to pop. + guard let navigationController else { return false } + return navigationController.viewControllers.count > 1 } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index cd57756ea6..fd58fd587e 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -40,6 +40,7 @@ let englishStrings: [String: String] = [ "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_back": "Back", "customer_center_done": "Done", "customer_center_cancel": "Cancel", // Customer Center – paths diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 310383f7b4..21c4b351d7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -14,22 +14,33 @@ public struct CustomerCenterNavigationOptions { public var usesExistingNavigation: Bool /// Shows a close button in the trailing toolbar position. public var showsCloseButton: Bool + /// Shows a back button in the leading toolbar position. Use this when the view supplies its own + /// navigation but sits inside a stack you own, so the button can take the user back out of it. + public var showsBackButton: Bool /// Called when the close button is tapped. `nil` uses the environment dismiss action. public var onClose: (() -> Void)? + /// Called when the back button is tapped. `nil` uses the environment dismiss action. + public var onBack: (() -> 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. + /// - showsBackButton: Shows a back button in the leading toolbar position. /// - onClose: Called when the close button is tapped. `nil` uses the environment dismiss action. + /// - onBack: Called when the back button is tapped. `nil` uses the environment dismiss action. public init( usesExistingNavigation: Bool = false, showsCloseButton: Bool = true, - onClose: (() -> Void)? = nil + showsBackButton: Bool = false, + onClose: (() -> Void)? = nil, + onBack: (() -> Void)? = nil ) { self.usesExistingNavigation = usesExistingNavigation self.showsCloseButton = showsCloseButton + self.showsBackButton = showsBackButton self.onClose = onClose + self.onBack = onBack } /// The default navigation options: wraps in its own `NavigationView` and shows a close button. @@ -123,10 +134,25 @@ public struct CustomerCenterView: View { .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. + // `ToolbarContentBuilder`'s conditional (`if`) support needs iOS 16, so the buttons are toggled + // here at the plain `@ViewBuilder` level instead, which iOS 15 supports. Branching on these + // flags is safe even though branch flips tear down modifiers: both come from + // `navigationOptions`, which is fixed for the view's lifetime, so neither can flip mid-update. + // + // The leading item is only attached when a back button is actually wanted — an always-present + // leading `ToolbarItem` would displace the automatic back button that the host's stack supplies + // in `usesExistingNavigation` mode. @ViewBuilder private var screenContent: some View { + if navigationOptions.showsBackButton { + closeConfiguredContent.toolbar { backButtonToolbarItem } + } else { + closeConfiguredContent + } + } + + @ViewBuilder + private var closeConfiguredContent: some View { if navigationOptions.showsCloseButton { coreContent.toolbar { closeButtonToolbarItem } } else { @@ -160,6 +186,18 @@ public struct CustomerCenterView: View { } } + private var backButtonToolbarItem: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + if let onBack = navigationOptions.onBack { onBack() } else { dismiss() } + } label: { + Image(systemName: "chevron.backward") + } + .accessibilityLabel(viewModel.strings.string("customer_center_back")) + .accessibilityIdentifier("customer_center.back") + } + } + private var theme: CustomerCenterTheme { CustomerCenterTheme(appearance: viewModel.configuration.appearance, colorScheme: colorScheme) } diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 5771f4b60b..1c8782e524 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -19,13 +19,30 @@ Present it over your current view controller with ``Superwall/presentCustomerCen Superwall.shared.presentCustomerCenter() ``` -Or embed it directly using ``CustomerCenterViewController``: +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 shows a back button instead of a close button, and hides your navigation +bar for as long as it is on screen. It supplies its own navigation bar in place of yours, because +its drill-downs — purchase history and per-purchase detail — need a SwiftUI navigation stack that +a `UINavigationController` can't provide. Your bar is restored exactly as it was found when the +user leaves, and swipe-to-go-back keeps working throughout. + ### Presenting from SwiftUI Use the ``SwiftUICore/View/presentSuperwallCustomerCenter(isPresented:configuration:onDismiss:)`` modifier to present it as a sheet: diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 7beec33cf2..3d6ad5c7e7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "لم يتم العثور على اشتراكات"; "customer_center_no_purchases_subtitle" = "يمكننا التحقق من عمليات الشراء السابقة."; "customer_center_close" = "إغلاق"; +"customer_center_back" = "رجوع"; "customer_center_done" = "تم"; "customer_center_cancel" = "إلغاء"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index 6a5e50bbca..ea4413f38f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Enrere"; "customer_center_done" = "Fet"; "customer_center_cancel" = "Cancel·la"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index f5c1ba3f7a..67fbc2b033 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Zpět"; "customer_center_done" = "Hotovo"; "customer_center_cancel" = "Zrušit"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 4746ccb920..a7882e6025 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Tilbage"; "customer_center_done" = "Udført"; "customer_center_cancel" = "Annuller"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 0e33205e7a..9098b53cff 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Zurück"; "customer_center_done" = "Fertig"; "customer_center_cancel" = "Abbrechen"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 46b25dc248..a21f0cd17e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Δεν βρέθηκαν συνδρομές"; "customer_center_no_purchases_subtitle" = "Μπορούμε να ελέγξουμε για προηγούμενες αγορές."; "customer_center_close" = "Κλείσιμο"; +"customer_center_back" = "Πίσω"; "customer_center_done" = "Τέλος"; "customer_center_cancel" = "Ακύρωση"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 5c0c574325..a4fbcaa550 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Back"; "customer_center_done" = "Done"; "customer_center_cancel" = "Cancel"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 5c0c574325..a4fbcaa550 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Back"; "customer_center_done" = "Done"; "customer_center_cancel" = "Cancel"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 5c0c574325..a4fbcaa550 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Back"; "customer_center_done" = "Done"; "customer_center_cancel" = "Cancel"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index 0079da22c3..ad48779173 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Atrás"; "customer_center_done" = "Listo"; "customer_center_cancel" = "Cancelar"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 894f8ac43e..fc3ca6b983 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Atrás"; "customer_center_done" = "Listo"; "customer_center_cancel" = "Cancelar"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 031e9bd31d..308a570612 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Tilauksia ei löytynyt"; "customer_center_no_purchases_subtitle" = "Voimme tarkistaa aiemmat ostokset."; "customer_center_close" = "Sulje"; +"customer_center_back" = "Takaisin"; "customer_center_done" = "Valmis"; "customer_center_cancel" = "Peruuta"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 7ac7c7eb6c..4efa7a5be2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Retour"; "customer_center_done" = "Terminé"; "customer_center_cancel" = "Annuler"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 836707b6a0..157a53bad3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Retour"; "customer_center_done" = "Terminé"; "customer_center_cancel" = "Annuler"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 7400cbc7bf..a7c880dbad 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "לא נמצאו מנויים"; "customer_center_no_purchases_subtitle" = "נוכל לבדוק אם יש רכישות קודמות."; "customer_center_close" = "סגירה"; +"customer_center_back" = "חזרה"; "customer_center_done" = "סיום"; "customer_center_cancel" = "ביטול"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index a6ca0a603b..ce21a59ccd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "कोई सदस्यता नहीं मिली"; "customer_center_no_purchases_subtitle" = "हम पिछली खरीदारी की जांच कर सकते हैं।"; "customer_center_close" = "बंद करें"; +"customer_center_back" = "वापस"; "customer_center_done" = "हो गया"; "customer_center_cancel" = "रद्द करें"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index d86ea3b7e6..8ba23ade30 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Natrag"; "customer_center_done" = "Gotovo"; "customer_center_cancel" = "Odustani"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index effacbd81a..77e06816f8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Vissza"; "customer_center_done" = "Kész"; "customer_center_cancel" = "Mégse"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index e85cb90103..891250a715 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Kembali"; "customer_center_done" = "Selesai"; "customer_center_cancel" = "Batal"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 681c8e04e9..8eca6aa370 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Indietro"; "customer_center_done" = "Fatto"; "customer_center_cancel" = "Annulla"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index cc94085595..c972fb95ce 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "サブスクリプションが見つかりません"; "customer_center_no_purchases_subtitle" = "以前の購入を確認できます。"; "customer_center_close" = "閉じる"; +"customer_center_back" = "戻る"; "customer_center_done" = "完了"; "customer_center_cancel" = "キャンセル"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 37d40c0716..916c1e7faa 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "구독을 찾을 수 없습니다"; "customer_center_no_purchases_subtitle" = "이전 구매 내역을 확인할 수 있습니다."; "customer_center_close" = "닫기"; +"customer_center_back" = "뒤로"; "customer_center_done" = "완료"; "customer_center_cancel" = "취소"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index f205fa6adc..9cc30c396f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Tiada langganan ditemui"; "customer_center_no_purchases_subtitle" = "Kami boleh menyemak pembelian terdahulu."; "customer_center_close" = "Tutup"; +"customer_center_back" = "Kembali"; "customer_center_done" = "Selesai"; "customer_center_cancel" = "Batal"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index c86c44b1fd..1ebeef7df8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Tilbake"; "customer_center_done" = "Ferdig"; "customer_center_cancel" = "Avbryt"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index 3115ea98f9..fc6b59412a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Terug"; "customer_center_done" = "Gereed"; "customer_center_cancel" = "Annuleren"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 215e58d9dc..20b4d8acff 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Tilbake"; "customer_center_done" = "Ferdig"; "customer_center_cancel" = "Avbryt"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 784b38e891..3b058179b6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Nie znaleziono subskrypcji"; "customer_center_no_purchases_subtitle" = "Możemy sprawdzić poprzednie zakupy."; "customer_center_close" = "Zamknij"; +"customer_center_back" = "Wstecz"; "customer_center_done" = "Gotowe"; "customer_center_cancel" = "Anuluj"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index 89219171b9..b7aec4c36e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; "customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; "customer_center_close" = "Fechar"; +"customer_center_back" = "Voltar"; "customer_center_done" = "Concluído"; "customer_center_cancel" = "Cancelar"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 8f6f624f9a..18ecb22c6c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; "customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; "customer_center_close" = "Fechar"; +"customer_center_back" = "Voltar"; "customer_center_done" = "Concluído"; "customer_center_cancel" = "Cancelar"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index a0bae52daf..5a290dc505 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Nenhuma subscrição encontrada"; "customer_center_no_purchases_subtitle" = "Podemos verificar compras anteriores."; "customer_center_close" = "Fechar"; +"customer_center_back" = "Voltar"; "customer_center_done" = "Concluído"; "customer_center_cancel" = "Cancelar"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 2b5d27061a..7f2df3dfb0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Înapoi"; "customer_center_done" = "Terminat"; "customer_center_cancel" = "Anulează"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index cf54ebc4a1..fc2233088b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Подписки не найдены"; "customer_center_no_purchases_subtitle" = "Мы можем проверить наличие предыдущих покупок."; "customer_center_close" = "Закрыть"; +"customer_center_back" = "Назад"; "customer_center_done" = "Готово"; "customer_center_cancel" = "Отмена"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index 98c19b1c2b..75b95eb97f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Späť"; "customer_center_done" = "Hotovo"; "customer_center_cancel" = "Zrušiť"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 0cd5fb3e95..3266d16d14 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Nazaj"; "customer_center_done" = "Končano"; "customer_center_cancel" = "Prekliči"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index fe430ae6ad..2af3c5c189 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Tillbaka"; "customer_center_done" = "Klar"; "customer_center_cancel" = "Avbryt"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 5644dc70e5..9a53e7c52e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "ไม่พบการสมัครสมาชิก"; "customer_center_no_purchases_subtitle" = "เราสามารถตรวจสอบการซื้อก่อนหน้านี้ได้"; "customer_center_close" = "ปิด"; +"customer_center_back" = "กลับ"; "customer_center_done" = "เสร็จสิ้น"; "customer_center_cancel" = "ยกเลิก"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 323e89eac5..f78fd72b89 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Geri"; "customer_center_done" = "Bitti"; "customer_center_cancel" = "İptal"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index acd61226d8..11217a0dc2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "Підписок не знайдено"; "customer_center_no_purchases_subtitle" = "Ми можемо перевірити попередні покупки."; "customer_center_close" = "Закрити"; +"customer_center_back" = "Назад"; "customer_center_done" = "Готово"; "customer_center_cancel" = "Скасувати"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 37ab3c1e8f..f3e33e936e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -42,6 +42,7 @@ "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_back" = "Quay lại"; "customer_center_done" = "Xong"; "customer_center_cancel" = "Hủy"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 91138ddce6..0b91d5c490 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "未找到订阅"; "customer_center_no_purchases_subtitle" = "我们可以检查以前的购买记录。"; "customer_center_close" = "关闭"; +"customer_center_back" = "返回"; "customer_center_done" = "完成"; "customer_center_cancel" = "取消"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index ce8dd8fd5c..c1bc3a2dc5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -42,6 +42,7 @@ "customer_center_no_purchases_title" = "找不到訂閱"; "customer_center_no_purchases_subtitle" = "我們可以查詢先前的購買記錄。"; "customer_center_close" = "關閉"; +"customer_center_back" = "返回"; "customer_center_done" = "完成"; "customer_center_cancel" = "取消"; diff --git a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift index eafa7e908f..cac691439d 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift @@ -10,7 +10,7 @@ import Foundation import UIKit @testable import SuperwallKit -@Suite("CustomerCenterManager") +@Suite("CustomerCenterManager", .serialized) @MainActor struct CustomerCenterManagerTests { @available(iOS 15.0, *) @@ -102,14 +102,18 @@ struct CustomerCenterManagerTests { var didDismissCount = 0 var delegate: ProbeDelegate? = ProbeDelegate { didDismissCount += 1 } let adapter = CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil) - let controller = CustomerCenterViewController(viewModel: viewModel, adapter: adapter) + 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 } - // Hostless: the controller isn't in a window, so `presentingViewController == nil` takes the - // same teardown branch a real dismissal would. + // 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) diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift new file mode 100644 index 0000000000..06c4fa5bd9 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -0,0 +1,219 @@ +// +// 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? + ) -> CustomerCenterViewController { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + let viewModel = CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + 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 + } + + @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 + + @available(iOS 15.0, *) + @Test("pushed hides the host's navigation bar while on screen and restores it on the way out") + func pushedTakesOverTheHostBar() { + let controller = makeController(style: .pushed, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + navigation.setNavigationBarHidden(false, animated: false) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { navigation.isNavigationBarHidden } + #expect(navigation.isNavigationBarHidden) + + navigation.popViewController(animated: false) + spinRunLoop(timeout: 1) { !navigation.isNavigationBarHidden } + #expect(!navigation.isNavigationBarHidden, "the host's bar should be handed back as it was found") + + window.isHidden = true + } + + @available(iOS 15.0, *) + @Test("pushed leaves an already-hidden host bar hidden") + func pushedRestoresAnAlreadyHiddenBar() { + let controller = makeController(style: .pushed, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + navigation.setNavigationBarHidden(true, animated: false) + 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) { controller.viewIfLoaded?.window == nil } + + #expect(navigation.isNavigationBarHidden) + + window.isHidden = true + } + + /// Taking over the host's bar is gated on the style, not on merely finding a navigation + /// controller: a `.modal` controller that happens to be inside one must leave it alone. + @available(iOS 15.0, *) + @Test("modal style leaves the host's navigation bar alone even on a stack") + func modalStyleLeavesTheBarAlone() { + let controller = makeController(style: .modal, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + navigation.setNavigationBarHidden(false, animated: false) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + #expect(!navigation.isNavigationBarHidden) + + 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") + } +} From 28691d5d60bc97521fcbc5793b47e5f19b201ed5 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Tue, 25 Aug 2026 15:24:03 -0500 Subject: [PATCH 35/42] fix(customer-center): close the review gaps in the pushed presentation style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from review of a5b8fc1. The view-controller guard only closed half of "covered is not dismissed". SwiftUI's `onDisappear` still ran on a cover — `UIHostingController` forwards the disappearance either way — dropping the visible-surface count to zero and arming the 0.6s debounce, so the dismissal simply arrived late and latched, silencing the genuine teardown. The controller now vetoes that pending dismissal when it knows it is merely being covered. The existing cover test asserted synchronously and so could never have caught this; the new one waits past the debounce and then pops, asserting the real teardown still lands. That test then exposed a second hole: a controller covered *and then* popped never gets a second `viewDidDisappear`, so its teardown was never delivered at all. Removal from a container is now handled in `didMove(toParent:)`, with the delivery latched because an ordinary pop is both a disappearance and a removal. Host navigation state was written more widely than it was restored. `isEnabled` was forced true with nothing putting it back, permanently re-enabling swipe-to-go-back for a host that had deliberately turned it off — and it turns out hiding the bar doesn't clear `isEnabled` anyway, so the line only ever did harm. It's gone, and the delegate capture now has the same idempotency guard as the bar's. The pop gesture also stayed armed while the user was inside the Customer Center's own stack, where two edge-pans were live for one swipe with no failure requirement between them; if the host's had won, the user would have been thrown out of the Customer Center entirely rather than going back one screen. It now stands down whenever a pushed surface is on screen. Also documents that a host-constructed controller is independent of the SDK's own presentation, since `presentCustomerCenter()` will happily stack a second one over it and `dismissCustomerCenter()` is a no-op on it. The support-email extension moves to its own file to keep the view model under the file length limit. Co-Authored-By: Claude Opus 5 --- .../UIKit/CustomerCenterViewController.swift | 68 ++++++++++-- .../CustomerCenterViewModel+Support.swift | 43 +++++++ .../ViewModel/CustomerCenterViewModel.swift | 90 +++++++-------- .../Views/ManagementScreenView.swift | 4 +- .../Views/PurchaseHistoryView.swift | 8 +- .../Documentation.docc/CustomerCenter.md | 10 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../CustomerCenterViewControllerTests.swift | 105 +++++++++++++++++- 8 files changed, 260 insertions(+), 72 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+Support.swift diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 5761eab51c..06559bb3d4 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -56,6 +56,9 @@ public final class CustomerCenterViewController: UIHostingController Bool { // Swiping on the stack's root would leave UIKit mid-transition with nothing to pop. - guard let navigationController else { return false } - return navigationController.viewControllers.count > 1 + guard let navigationController, navigationController.viewControllers.count > 1 else { + return false + } + // Stand down while the user is inside the Customer Center's own stack — on purchase history + // or a purchase detail. Both stacks have an edge-pan armed for the same swipe with no failure + // requirement between them, and if the host's were to win, the user would be thrown out of the + // whole Customer Center instead of going back one screen. Their own back button still works. + return viewModel?.isShowingPushedSurface != true } } 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.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index a369eb5072..0fcb751402 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -35,7 +35,9 @@ final class CustomerCenterViewModel: ObservableObject { /// (`SuperwallOptions.localeIdentifier` when set) rather than the system locale. var locale: Locale { dependencies.environment.locale } - private let dependencies: CustomerCenterDependencies + // 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] = [:] @@ -52,13 +54,16 @@ final class CustomerCenterViewModel: ObservableObject { private var hasTrackedOpen = false private var didDismiss = false /// Active entitlement identifiers from the latest `CustomerInfo`, for support diagnostics. - private var activeEntitlementIds: [String] = [] + 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 + /// Of those surfaces, how many the Customer Center pushed onto its own stack. Zero means the + /// user is on its root screen. + private var pushedSurfaceCount = 0 private var dismissDebounceTask: Task? init( @@ -319,26 +324,31 @@ final class CustomerCenterViewModel: ObservableObject { @available(iOS 15.0, *) extension CustomerCenterViewModel { - /// Call from any Customer Center surface's `onAppear` — the root view, and any screen it - /// pushes itself (purchase detail, purchase history, purchase detail rows). In embedded mode - /// (`usesExistingNavigation`) the host owns the navigation stack, so pushing one of these - /// screens removes the previous surface from the hierarchy without the Customer Center - /// actually closing. Counting concurrently visible surfaces (instead of a single boolean) - /// correctly tracks nested pushes, and cancels any pending dismissal from a prior disappear. - func surfaceDidAppear() { + /// 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. + /// - Parameter isPushed: `true` for a screen pushed onto the Customer Center's own stack, `false` + /// for the root view. Tracked separately — see ``isShowingPushedSurface``. + func surfaceDidAppear(isPushed: Bool = false) { visibleSurfaceCount += 1 + if isPushed { + pushedSurfaceCount += 1 + } dismissDebounceTask?.cancel() dismissDebounceTask = nil } - /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear()``. - /// When the count drops to zero, waits a short debounce before dismissing — a push/pop - /// transition can briefly have both the old and new surface on screen, or neither, so a - /// single runloop turn isn't enough to distinguish "navigating within the Customer Center" - /// from "the Customer Center was torn down". If another surface appears before the debounce - /// elapses, ``surfaceDidAppear()`` cancels it and no dismissal happens. - func surfaceDidDisappear() { + /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear(isPushed:)``. + /// 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(isPushed: Bool = false) { visibleSurfaceCount = max(0, visibleSurfaceCount - 1) + if isPushed { + pushedSurfaceCount = max(0, pushedSurfaceCount - 1) + } guard visibleSurfaceCount == 0 else { return } dismissDebounceTask?.cancel() // Captures self strongly: on the SwiftUI sheet path the last `onDisappear` is immediately @@ -353,6 +363,19 @@ extension CustomerCenterViewModel { } } + /// Whether the user is currently on a screen the Customer Center pushed onto its own stack, + /// rather than on its root. + var isShowingPushedSurface: Bool { pushedSurfaceCount > 0 } + + /// 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 + } + func dismiss() { guard !didDismiss else { return } didDismiss = true @@ -362,38 +385,3 @@ extension CustomerCenterViewModel { Task { await dependencies.tracker.track(InternalSuperwallEvent.CustomerCenterClose()) } } } - -// 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/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index e978c605f7..49f276de00 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -96,7 +96,7 @@ struct PurchaseDetailScreenView: View { .listStyle(.insetGrouped) .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.surfaceDidAppear() } - .onDisappear { viewModel.surfaceDidDisappear() } + .onAppear { viewModel.surfaceDidAppear(isPushed: true) } + .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index ce0c5907b6..053bb721ae 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -22,8 +22,8 @@ struct PurchaseHistoryView: View { .listStyle(.insetGrouped) .navigationTitle(strings.string("customer_center_purchase_history")) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.surfaceDidAppear() } - .onDisappear { viewModel.surfaceDidDisappear() } + .onAppear { viewModel.surfaceDidAppear(isPushed: true) } + .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } } @ViewBuilder @@ -87,8 +87,8 @@ struct PurchaseDetailRows: View { } .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.surfaceDidAppear() } - .onDisappear { viewModel.surfaceDidDisappear() } + .onAppear { viewModel.surfaceDidAppear(isPushed: true) } + .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } } private func row(_ label: String, _ value: String) -> some View { diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 1c8782e524..f9aed1fd2d 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -41,7 +41,15 @@ A pushed Customer Center shows a back button instead of a close button, and hide bar for as long as it is on screen. It supplies its own navigation bar in place of yours, because its drill-downs — purchase history and per-purchase detail — need a SwiftUI navigation stack that a `UINavigationController` can't provide. Your bar is restored exactly as it was found when the -user leaves, and swipe-to-go-back keeps working throughout. +user leaves, and swipe-to-go-back keeps working — except while the user is drilled into the +Customer Center's own screens, where the back button takes them up one level instead. + +> 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 diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 1f73afdd9e..e824302769 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -551,6 +551,7 @@ E23BC32639E66B3992FB6959 /* PurchaseHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E705F9954F808C341A4D0EBD /* PurchaseHistoryView.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 */; }; @@ -947,6 +948,7 @@ 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 = ""; }; 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagementScreenViewTests.swift; sourceTree = ""; }; 7FCE6A59348C9018F40D7AC5 /* LogScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogScope.swift; sourceTree = ""; }; @@ -2317,6 +2319,7 @@ children = ( 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */, + 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */, ); path = ViewModel; sourceTree = ""; @@ -3780,6 +3783,7 @@ 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */, + E38FE7475E82AC6EA10F2C65 /* CustomerCenterViewModel+Support.swift in Sources */, 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, 8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */, E7FD108C357A816AF8BFBA47 /* DarkBlurredBackground.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift index 06c4fa5bd9..ebfb315e43 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -23,12 +23,18 @@ struct CustomerCenterViewControllerTests { @available(iOS 15.0, *) private func makeController( style: CustomerCenterPresentationStyle, - delegate: CustomerCenterDelegate? + 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) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english, + dismissDebounceInterval: dismissDebounceInterval + ) return CustomerCenterViewController( viewModel: viewModel, adapter: CustomerCenterDelegateAdapter(swiftDelegate: delegate, objcDelegate: nil), @@ -87,6 +93,40 @@ struct CustomerCenterViewControllerTests { 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 } + 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 + } + @available(iOS 15.0, *) @Test("pushed: being popped off the host's stack fires the dismissal exactly once") func pushedPopFiresDismissal() { @@ -208,6 +248,67 @@ struct CustomerCenterViewControllerTests { window.isHidden = true } + /// Every host property the pushed style writes has to come back exactly as it was found — + /// including for a host that deliberately turned swipe-to-go-back off. + @available(iOS 15.0, *) + @Test("pushed restores the pop recognizer's delegate and never writes its enablement") + func pushedRoundTripsTheInteractivePopGesture() { + for hostEnabled in [true, false] { + let controller = makeController(style: .pushed, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + let recognizer = navigation.interactivePopGestureRecognizer + recognizer?.isEnabled = hostEnabled + let hostDelegate = recognizer?.delegate + + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + #expect(recognizer?.isEnabled == hostEnabled, "the host's enablement must not be overwritten") + + navigation.popViewController(animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + + #expect(recognizer?.delegate === hostDelegate) + #expect(recognizer?.isEnabled == hostEnabled) + + window.isHidden = true + } + } + + /// Both stacks arm an edge-pan for the same swipe. While the user is inside the Customer + /// Center's own stack the host's must stand down, or the swipe throws them out of the whole + /// Customer Center instead of going back one screen. + @available(iOS 15.0, *) + @Test("the host's pop gesture stands down while drilled into the Customer Center's own stack") + func hostPopGestureDefersToTheInnerStack() async { + let controller = makeController(style: .pushed, delegate: nil) + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + guard let recognizer = navigation.interactivePopGestureRecognizer, + let delegate = recognizer.delegate else { + Issue.record("expected the pushed style to install a pop gesture delegate") + return + } + + // At the Customer Center's root, swiping back out of it is right. + #expect(delegate.gestureRecognizerShouldBegin?(recognizer) == true) + + // Drilled in — the inner stack owns the gesture now. + controller.viewModel.surfaceDidAppear(isPushed: true) + #expect(delegate.gestureRecognizerShouldBegin?(recognizer) == false) + + // Back at the root, it's ours again. + controller.viewModel.surfaceDidDisappear(isPushed: true) + #expect(delegate.gestureRecognizerShouldBegin?(recognizer) == true) + + window.isHidden = true + } + // MARK: - Analytics @available(iOS 15.0, *) From 0b90c4d97e2ad38d0c70fcf5ca1c0a3696621b2b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 12:08:56 -0500 Subject: [PATCH 36/42] feat(customer-center): look the latest app version up from the App Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update banner only fired when `latestAppVersion` was kept current by hand, so it went quiet the moment a release shipped without someone editing config. It now finds the published version itself via Apple's public lookup endpoint, cached for 24 hours. The comparison stays "installed is older than published" rather than "differs from published". A TestFlight or internal build is normally numbered *above* the App Store, so equality would tell every tester to update — and send them to an older build. Calendar versions order correctly under the same numeric comparison, since they're monotonic tuples like semantic ones. The lookup is skipped entirely on TestFlight, sandbox and simulator builds, when the host set `latestAppVersion` (which stays authoritative), and when `checksAppStoreForUpdates` is off. Any failure — offline, no listing, unparseable version — hides the banner and logs. Public rather than the App Store Connect API: Connect authenticates with a signed JWT, and the key that signs it can't ship in a client. `Support` gains a hand-written decoder so configuration JSON written before the flag existed still decodes, which matters for the dashboard-served config this model is shaped for. Since the version arrives after the screen has loaded, the banner's insertion is animated rather than appearing from nowhere, honouring Reduce Motion. Splits `Appearance` and the update-banner logic into their own files to stay under the file and type length limits. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../Logic/AppStoreVersionLookup.swift | 136 +++++ ...stomerCenterConfiguration+Appearance.swift | 97 ++++ .../Models/CustomerCenterConfiguration.swift | 116 +--- .../CustomerCenterDependencies.swift | 4 +- ...CustomerCenterViewModel+UpdateBanner.swift | 54 ++ .../ViewModel/CustomerCenterViewModel.swift | 23 +- .../Views/ManagementScreenView.swift | 4 + .../Documentation.docc/CustomerCenter.md | 32 ++ SuperwallKit.xcodeproj/project.pbxproj | 20 + .../Logic/AppStoreUpdateCheckTests.swift | 186 ++++++ .../CustomerCenterDependenciesMocks.swift | 13 +- .../Views/DesignReviewSnapshots.swift | 528 ++++++++++++++++++ 13 files changed, 1111 insertions(+), 103 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+UpdateBanner.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7f8c72e3..1ef3a7b215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. - `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. ### Fixes diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift new file mode 100644 index 0000000000..46b482c354 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift @@ -0,0 +1,136 @@ +// +// 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. +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" + + let bundleId: String? + /// Two-letter region for the storefront to query. Versions differ by region during a phased + /// release, so asking for the wrong one can report a version this device can't install. + let regionCode: String? + let defaults: UserDefaults + let session: URLSession + let now: () -> Date + + init( + bundleId: String? = Bundle.main.bundleIdentifier, + regionCode: String? = Locale.current.regionCode, + 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 + } + + 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"). " + + "The update banner won't show. 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 + else { + return nil + } + return version + } + + private func cache(_ version: String) { + defaults.set(version, forKey: Self.versionKey) + defaults.set(now(), forKey: Self.fetchedAtKey) + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift new file mode 100644 index 0000000000..6fc90ee64b --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift @@ -0,0 +1,97 @@ +// +// CustomerCenterConfiguration+Appearance.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Foundation +import UIKit + +extension CustomerCenterConfiguration { + // MARK: - Appearance + + @objc(SWKCustomerCenterAppearance) + @objcMembers + public final class Appearance: NSObject, Codable { + public var accent: ColorPair? + public var background: ColorPair? + public var text: ColorPair? + public var buttonText: ColorPair? + public var buttonBackground: ColorPair? + + public init( + accent: ColorPair? = nil, + background: ColorPair? = nil, + text: ColorPair? = nil, + buttonText: ColorPair? = nil, + buttonBackground: ColorPair? = nil + ) { + self.accent = accent + self.background = background + self.text = text + self.buttonText = buttonText + self.buttonBackground = buttonBackground + } + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Appearance else { return false } + return accent == other.accent && background == other.background && text == other.text + && buttonText == other.buttonText && buttonBackground == other.buttonBackground + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(accent) + hasher.combine(background) + hasher.combine(text) + hasher.combine(buttonText) + hasher.combine(buttonBackground) + 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.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index 5f92ad14d7..ce26601634 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -8,8 +8,6 @@ import Foundation import UIKit -// swiftlint:disable type_body_length - /// 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 @@ -262,6 +260,13 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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? @@ -269,19 +274,39 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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 } @@ -290,94 +315,9 @@ public final class CustomerCenterConfiguration: NSObject, Codable { hasher.combine(email) hasher.combine(latestAppVersion) hasher.combine(shouldWarnToUpdate) + hasher.combine(checksAppStoreForUpdates) hasher.combine(webManagementURL) return hasher.finalize() } } - - // MARK: - Appearance - - @objc(SWKCustomerCenterAppearance) - @objcMembers - public final class Appearance: NSObject, Codable { - public var accent: ColorPair? - public var background: ColorPair? - public var text: ColorPair? - public var buttonText: ColorPair? - public var buttonBackground: ColorPair? - - public init( - accent: ColorPair? = nil, - background: ColorPair? = nil, - text: ColorPair? = nil, - buttonText: ColorPair? = nil, - buttonBackground: ColorPair? = nil - ) { - self.accent = accent - self.background = background - self.text = text - self.buttonText = buttonText - self.buttonBackground = buttonBackground - } - - override public func isEqual(_ object: Any?) -> Bool { - guard let other = object as? Appearance else { return false } - return accent == other.accent && background == other.background && text == other.text - && buttonText == other.buttonText && buttonBackground == other.buttonBackground - } - - override public var hash: Int { - var hasher = Hasher() - hasher.combine(accent) - hasher.combine(background) - hasher.combine(text) - hasher.combine(buttonText) - hasher.combine(buttonBackground) - 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/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 58e0ea2376..77541f750e 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -54,6 +54,7 @@ struct CustomerCenterDependencies { var tracker: CustomerCenterEventTracking var environment: CustomerCenterEnvironmentProviding var transactionLookup: StoreKitTransactionLooking + var appStoreVersion: CustomerCenterAppStoreVersionProviding } enum WebManagementURLResolver { @@ -167,7 +168,8 @@ extension CustomerCenterDependencies { urlOpener: LiveURLOpener(), tracker: LiveEventTracker(), environment: LiveEnvironment(container: container, webManagementOverride: configuration.support.webManagementURL), - transactionLookup: StoreKitTransactionLookup() + transactionLookup: StoreKitTransactionLookup(), + appStoreVersion: AppStoreVersionLookup() ) } } 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 index 0fcb751402..80d769d3f7 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -22,7 +22,9 @@ final class CustomerCenterViewModel: ObservableObject { } @Published var restoreState: CustomerCenterRestoreState = .idle @Published private(set) var refundResult: (productId: String, status: CustomerCenterRefundStatus)? - @Published private(set) var showsUpdateBanner = false + // 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 let configuration: CustomerCenterConfiguration @@ -50,7 +52,10 @@ final class CustomerCenterViewModel: ObservableObject { /// 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? - private var updateWarningDismissed = false + 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. @@ -103,6 +108,9 @@ final class CustomerCenterViewModel: ObservableObject { func load() async { let info = await dependencies.customerInfo.fetchCustomerInfo() await apply(customerInfo: info, refetchProducts: true) + // Deliberately after the first `apply`: the screen renders straight away rather than waiting + // on a network round trip, and the banner animates in afterwards if there's something to say. + await refreshAppStoreVersion() if !hasTrackedOpen { hasTrackedOpen = true await dependencies.tracker.track( @@ -129,12 +137,7 @@ final class CustomerCenterViewModel: ObservableObject { purchases = builder.build(customerInfo: customerInfo, products: products) activeEntitlementIds = customerInfo.entitlements.filter(\.isActive).map(\.id) state = hasAnyPurchases(customerInfo) ? .management : .noPurchases - showsUpdateBanner = !updateWarningDismissed - && configuration.support.shouldWarnToUpdate - && AppVersionComparator.isInstalledVersion( - dependencies.environment.appVersion, - olderThan: configuration.support.latestAppVersion - ) + recomputeUpdateBanner() let activeStores = Set(customerInfo.subscriptions.filter(\.isActive).map(\.store)) showsDuplicateBanner = configuration.warnsAboutDuplicateSubscriptions && activeStores.contains(.appStore) @@ -304,10 +307,6 @@ final class CustomerCenterViewModel: ObservableObject { await apply(customerInfo: info, refetchProducts: true) } - func continueAfterUpdateWarning() { - updateWarningDismissed = true - showsUpdateBanner = false - } // swiftlint:disable:next large_tuple func historySections() -> ( diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 49f276de00..41f6d0f0f1 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -11,6 +11,7 @@ import SwiftUI struct ManagementScreenView: View { @ObservedObject var viewModel: CustomerCenterViewModel @Environment(\.customerCenterStrings) private var strings + @Environment(\.accessibilityReduceMotion) private var reduceMotion private var subscriptions: [PurchasePresentation] { viewModel.purchases.filter { $0.subscription != nil } } private var others: [PurchasePresentation] { viewModel.purchases.filter { $0.subscription == nil } } @@ -60,6 +61,9 @@ struct ManagementScreenView: View { } } .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) } diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index f9aed1fd2d..bfd8ff2c9d 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -131,6 +131,38 @@ plans, and contacting support; ``CustomerCenterConfiguration/PathType/url(_:open URL either in-app or externally, and ``CustomerCenterConfiguration/PathType/custom(identifier:)`` lets you handle an action entirely yourself via the delegate. +### 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. + ## The Delegate Implement ``CustomerCenterDelegate`` (or ``CustomerCenterDelegateObjc`` from Objective-C) to diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index e824302769..c48b379ea9 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 0EF8D358CA712DB3C45C1318 /* ConfirmHoldoutAssignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6752063E4547657E20072CE7 /* ConfirmHoldoutAssignment.swift */; }; 0F00D32C125E8B86EA477631 /* PurchaseControllerObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CC67D2CEA90B70D6AC99419 /* PurchaseControllerObjc.swift */; }; 0F632AEB4FDA9D90CFCBD1F7 /* ManagementScreenViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.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 */; }; @@ -202,6 +203,7 @@ 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 */; }; @@ -290,6 +292,7 @@ 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 */; }; @@ -323,6 +326,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 */; }; @@ -606,6 +610,7 @@ 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 */; }; @@ -666,6 +671,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 = ""; }; @@ -715,6 +721,7 @@ 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 = ""; }; @@ -860,6 +867,7 @@ 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 = ""; }; @@ -998,6 +1006,7 @@ 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 = ""; }; @@ -1016,6 +1025,7 @@ 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 = ""; }; @@ -1376,6 +1386,7 @@ children = ( F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */, 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, + 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */, 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */, ); path = Views; @@ -2014,6 +2025,7 @@ 4664D61C9B4C8ADC2B834E36 /* Logic */ = { isa = PBXGroup; children = ( + 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */, 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, @@ -2186,6 +2198,7 @@ 5E4DEFC8C051825F0007162E /* Logic */ = { isa = PBXGroup; children = ( + 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */, 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */, 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, @@ -2320,6 +2333,7 @@ 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */, 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */, + 96A6D71D51DB3F1BCE3E167B /* CustomerCenterViewModel+UpdateBanner.swift */, ); path = ViewModel; sourceTree = ""; @@ -2802,6 +2816,7 @@ children = ( 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */, 2E48D6D7B8E5EFCC2623446B /* CustomerCenterConfiguration.swift */, + 1F86C1F3253E4C8FFBCB30AC /* CustomerCenterConfiguration+Appearance.swift */, 710DB325AE1CA4988E2FB9CA /* CustomerCenterConfiguration+ObjC.swift */, 51B5BF7B93E59438467DB6C7 /* CustomerCenterScreenState.swift */, 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */, @@ -3546,6 +3561,7 @@ 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 */, @@ -3586,6 +3602,7 @@ 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 */, @@ -3718,6 +3735,7 @@ 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 */, @@ -3770,6 +3788,7 @@ 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 */, @@ -3784,6 +3803,7 @@ FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, 8901727EE5E2048125791BAB /* CustomerCenterViewController.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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift new file mode 100644 index 0000000000..21de06cd39 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift @@ -0,0 +1,186 @@ +// +// AppStoreUpdateCheckTests.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("App Store update check") +@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: - 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/ViewModel/CustomerCenterDependenciesMocks.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift index 3a556b74ce..c0d2e62242 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesMocks.swift @@ -85,6 +85,13 @@ struct EnvironmentMock: CustomerCenterEnvironmentProviding { 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, @@ -93,7 +100,8 @@ extension CustomerCenterDependencies { restorer: RestorerMock = RestorerMock(), urlOpener: URLOpenerMock = URLOpenerMock(), tracker: EventTrackerMock = EventTrackerMock(), - lookup: StoreKitTransactionLookupMock = StoreKitTransactionLookupMock() + lookup: StoreKitTransactionLookupMock = StoreKitTransactionLookupMock(), + appStoreVersion: AppStoreVersionProviderMock = AppStoreVersionProviderMock() ) -> (CustomerCenterDependencies, CustomerInfoProviderMock, ProductsProviderMock) { let infoProvider = CustomerInfoProviderMock(info) let productsProvider = ProductsProviderMock() @@ -105,7 +113,8 @@ extension CustomerCenterDependencies { urlOpener: urlOpener, tracker: tracker, environment: environment, - transactionLookup: lookup + transactionLookup: lookup, + appStoreVersion: appStoreVersion ) return (deps, infoProvider, productsProvider) } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift new file mode 100644 index 0000000000..05400798ab --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -0,0 +1,528 @@ +// +// 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 disabled by +// default and only runs when `CUSTOMER_CENTER_SNAPSHOT_DIR` is set: +// +// CUSTOMER_CENTER_SNAPSHOT_DIR=~/Desktop/customer-center-screens \ +// xcodebuild test -only-testing:SuperwallKitTests/DesignReviewSnapshots ... +// + +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 + ), + "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() + ) async -> CustomerCenterViewModel { + let (dependencies, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + subscriptions: subscriptions, + nonSubscriptions: nonSubscriptions, + entitlements: entitlements + ), + products: catalogue, + environment: environment + ) + 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) + } + } + + // 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. The single-purchase layout, which shows the + // purchase card and its actions together rather than a drill-down list. + 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. More one-off purchases than the management screen shows inline, with the purchase + // history screen available to show the rest. + 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-collapsed", directory: directory, viewModel: manyPurchases) + + // 13. The purchase history screen those rows lead to. + captureDetail("13-purchase-history", directory: directory, viewModel: manyPurchases) { + PurchaseHistoryView(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. History and account details switched off — the most stripped-back screen. + let minimal = defaultConfiguration() + minimal.showsPurchaseHistory = false + 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) + + 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)")) + } +} From baa2a95ef67e055d0235168271dd417cecde0cbd Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 12:11:14 -0500 Subject: [PATCH 37/42] docs(customer-center): record the phased-release limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The App Store lookup reports a new version the moment it goes live, but Apple rolls releases out over seven days — so early in a release some customers are told to update to a build they can't install yet. Accepted rather than solved, but it was only alluded to in a property comment. Now stated where someone hits it: the lookup type, the DocC article, and the changelog, each with the way out (set `latestAppVersion`, or turn the lookup off). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../CustomerCenter/Logic/AppStoreVersionLookup.swift | 8 ++++++++ Sources/SuperwallKit/Documentation.docc/CustomerCenter.md | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef3a7b215..26c8f8887b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. +- 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. - `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. ### Fixes diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift index 46b482c354..6b00a4e3b0 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift @@ -19,6 +19,14 @@ protocol CustomerCenterAppStoreVersionProviding { /// /// 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 diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index bfd8ff2c9d..887e30ba0c 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -163,6 +163,11 @@ version — hides the banner and logs under the `customerCenter` scope. > 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 From d75c8ce977ee65fdc550046ba7cece88122a3e9b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 12:45:59 -0500 Subject: [PATCH 38/42] fix(customer-center): make the web subscription path make sense MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems for customers who subscribed through Stripe or Paddle rather than the App Store. Their one management row read "Cancel subscription". That label is right on the App Store path, where the row carries the cancellation survey and opens Apple's cancel sheet, but a web management page also changes plans and updates cards, so the label undersold it. Web destinations now render "Manage subscription". With no management URL configured, the row disappeared entirely — leaving someone who is paying with no action beyond Restore. It now resolves to `.webManageUnavailable` and explains that the link is in their emailed receipt, which is worse than a working link and much better than nothing. Surveys fired before handing off to the browser. The survey exists to gate an action, but a web handoff leaves the app and its outcome is never observed, so the answer was attributed to something we can't see the end of. Web flows now skip the survey. Change plan and refund stay hidden for web, as they were: both are App Store-only, and a second and third row pointing at the same management page would be noise. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../Logic/CustomerCenterPathResolver.swift | 20 ++- .../Models/CustomerCenterScreenState.swift | 2 + .../ViewModel/CustomerCenterViewModel.swift | 4 +- .../Views/CustomerCenterSheets.swift | 4 +- .../Views/CustomerCenterStrings+English.swift | 2 + .../CustomerCenter/Views/PathsListView.swift | 13 +- .../ar.lproj/Localizable.strings | 2 + .../ca.lproj/Localizable.strings | 2 + .../cs.lproj/Localizable.strings | 2 + .../da.lproj/Localizable.strings | 2 + .../de.lproj/Localizable.strings | 2 + .../el.lproj/Localizable.strings | 2 + .../en.lproj/Localizable.strings | 2 + .../en_AU.lproj/Localizable.strings | 2 + .../en_GB.lproj/Localizable.strings | 2 + .../es.lproj/Localizable.strings | 2 + .../es_419.lproj/Localizable.strings | 2 + .../fi.lproj/Localizable.strings | 2 + .../fr.lproj/Localizable.strings | 2 + .../fr_CA.lproj/Localizable.strings | 2 + .../he.lproj/Localizable.strings | 2 + .../hi.lproj/Localizable.strings | 2 + .../hr.lproj/Localizable.strings | 2 + .../hu.lproj/Localizable.strings | 2 + .../id.lproj/Localizable.strings | 2 + .../it.lproj/Localizable.strings | 2 + .../ja.lproj/Localizable.strings | 2 + .../ko.lproj/Localizable.strings | 2 + .../ms.lproj/Localizable.strings | 2 + .../nb.lproj/Localizable.strings | 2 + .../nl.lproj/Localizable.strings | 2 + .../nn.lproj/Localizable.strings | 2 + .../pl.lproj/Localizable.strings | 2 + .../pt.lproj/Localizable.strings | 2 + .../pt_BR.lproj/Localizable.strings | 2 + .../pt_PT.lproj/Localizable.strings | 2 + .../ro.lproj/Localizable.strings | 2 + .../ru.lproj/Localizable.strings | 2 + .../sk.lproj/Localizable.strings | 2 + .../sl.lproj/Localizable.strings | 2 + .../sv.lproj/Localizable.strings | 2 + .../th.lproj/Localizable.strings | 2 + .../tr.lproj/Localizable.strings | 2 + .../uk.lproj/Localizable.strings | 2 + .../vi.lproj/Localizable.strings | 2 + .../zh_Hans.lproj/Localizable.strings | 2 + .../zh_Hant.lproj/Localizable.strings | 2 + SuperwallKit.xcodeproj/project.pbxproj | 4 + .../CustomerCenterPathResolverTests.swift | 7 +- .../Logic/WebSubscriptionPathTests.swift | 157 ++++++++++++++++++ .../CustomerCenterViewModelTests.swift | 9 +- 52 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c8f8887b..31dcd143ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. +- 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 shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. ### Fixes diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift index e583e02ef3..82dd9641bc 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -25,6 +25,10 @@ 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 @@ -38,6 +42,18 @@ struct ResolvedPath: Equatable, Identifiable { 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], @@ -84,8 +100,8 @@ enum CustomerCenterPathResolver { else { return nil } return .appleManageSheet(subscriptionGroupId: sub.subscriptionGroupId ?? context.product?.subscriptionGroupId) } - if isWebStore, let url = context.webManagementURL { - return .webManage(url) + if isWebStore { + return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } ?? .webManageUnavailable } return nil diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift index d5c1db8d14..236093c630 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterScreenState.swift @@ -17,6 +17,7 @@ enum CustomerCenterSheet: Identifiable, Equatable { case refund(transactionId: UInt64, productId: String) case safari(URL) case noMailApp(email: String) + case webManageUnavailable var id: String { switch self { @@ -27,6 +28,7 @@ enum CustomerCenterSheet: Identifiable, Equatable { case .refund(let transactionId, _): return "refund:\(transactionId)" case .safari(let url): return "safari:\(url.absoluteString)" case .noMailApp: return "nomail" + case .webManageUnavailable: return "webManageUnavailable" } } } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 80d769d3f7..1914aadea3 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -183,7 +183,7 @@ final class CustomerCenterViewModel: ObservableObject { await dependencies.tracker.track( InternalSuperwallEvent.CustomerCenterAction(action: action, pathId: resolved.path.id, productId: purchase?.productId) ) - if let survey = resolved.path.survey, !survey.options.isEmpty { + 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) @@ -227,6 +227,8 @@ final class CustomerCenterViewModel: ObservableObject { 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) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index 2092eebc95..b776e157ef 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -37,7 +37,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { .init( get: { switch viewModel.sheet { - case .survey, .changePlan, .safari, .noMailApp: return viewModel.sheet + case .survey, .changePlan, .safari, .noMailApp, .webManageUnavailable: return viewModel.sheet default: return nil } }, @@ -87,6 +87,8 @@ private struct CustomerCenterSheetsModifier: ViewModifier { 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() } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index fd58fd587e..88efebb6ff 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -49,6 +49,8 @@ let englishStrings: [String: String] = [ // 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_path_refund": "Request a refund", "customer_center_path_change_plan": "Change plan", "customer_center_path_contact_support": "Contact support", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift index 0330ecd6c1..28386739cf 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -31,7 +31,7 @@ struct PathsListView: View { // push — "See all purchases" and the purchase detail rows — are `NavigationLink`s and get // their chevron from SwiftUI. HStack { - Text(title(for: resolved.path)) + Text(title(for: resolved)) Spacer() if loadingPathId == resolved.id { ProgressView() @@ -43,11 +43,18 @@ struct PathsListView: View { } } - private func title(for path: CustomerCenterConfiguration.Path) -> String { + private func title(for resolved: ResolvedPath) -> 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: return strings.string("customer_center_path_manage_subscription") + 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") diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 3d6ad5c7e7..f8dfcbe1a0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "التواصل مع الدعم"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index ea4413f38f..673393c760 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 67fbc2b033..fb6af74ffe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index a7882e6025..59b742a3d5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 9098b53cff..88b819ad34 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index a21f0cd17e..b4c7d42757 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "Επικοινωνία με την υποστήριξη"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index a4fbcaa550..b007ae6bf4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index a4fbcaa550..b007ae6bf4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index a4fbcaa550..b007ae6bf4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index ad48779173..c2e583aa85 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index fc3ca6b983..4c6d2032f1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 308a570612..e0b45922b4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 4efa7a5be2..88575d98e7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 157a53bad3..adaa51b629 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index a7c880dbad..597bea5416 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "יצירת קשר עם התמיכה"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index ce21a59ccd..42571aad84 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "सहायता से संपर्क करें"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 8ba23ade30..8e9ae3e94d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index 77e06816f8..c7a19ac660 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index 891250a715..4f5cfd57e7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 8eca6aa370..1aa9d0dd93 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index c972fb95ce..4ec7a839d1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "サポートに問い合わせる"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 916c1e7faa..3cdffe76c6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "지원팀에 문의"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 9cc30c396f..9d24bbf398 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index 1ebeef7df8..73e1db92d7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index fc6b59412a..e496c8fd6d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 20b4d8acff..857b8d6b94 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 3b058179b6..08973efca7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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ą"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index b7aec4c36e..61e978fc3a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 18ecb22c6c..e99ab17700 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index 5a290dc505..19ae0e2ee5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 7f2df3dfb0..53e5960963 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index fc2233088b..74561c69fb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "Связаться со службой поддержки"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index 75b95eb97f..54476f8bb3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 3266d16d14..aa93aa8360 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index 2af3c5c189..d6c07ca2f6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 9a53e7c52e..f3598ebd30 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "ติดต่อฝ่ายสนับสนุน"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index f78fd72b89..65d81c265a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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ç"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 11217a0dc2..f63d2621d6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "Зв'язатися зі службою підтримки"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index f3e33e936e..d253312f6c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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ợ"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 0b91d5c490..756fec073f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "联系支持人员"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index c1bc3a2dc5..e538098526 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -49,6 +49,8 @@ /* 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" = "聯絡支援人員"; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index c48b379ea9..17868cb4fc 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -176,6 +176,7 @@ 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 */; }; @@ -1161,6 +1162,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 = ""; }; @@ -2030,6 +2032,7 @@ 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, + C262ABB80CCC8266542F26C5 /* WebSubscriptionPathTests.swift */, ); path = Logic; sourceTree = ""; @@ -3704,6 +3707,7 @@ 8B200F99B71D706D45948339 /* Utils.swift in Sources */, 6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */, 37264FFAF68B8349BD6F9BE8 /* WebEntitlementRedeemerTests.swift in Sources */, + 4A270686A4C804CDC85FB5B8 /* WebSubscriptionPathTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift index 7432c23730..f5ad4ee01d 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift @@ -106,11 +106,14 @@ struct CustomerCenterPathResolverTests { #expect(destinations(context(presentation(sub(group: nil), product: noGroup), product: noGroup), curated).isEmpty) } - @Test("web store sub: only webManage (when URL) + contactSupport; play store: contactSupport only") + @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]) - #expect(destinations(context(presentation(sub(store: .stripe), product: nil))) == [.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]) } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift new file mode 100644 index 0000000000..8bfbc87a29 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift @@ -0,0 +1,157 @@ +// +// 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) -> 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: store + ) + } + + private func makeViewModel( + store: ProductStore = .stripe, + webManagementURL: URL?, + survey: CustomerCenterConfiguration.FeedbackSurvey? = nil + ) async -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + 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)) + } + + // 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/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index 82595f7a4e..ed45ad2001 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -251,12 +251,11 @@ struct CustomerCenterViewModelTests { let purchase = vm.purchases[0] let manage = vm.paths(for: purchase).first { $0.path.id == "manage_subscription" }! vm.callbacks.didSelectAction = nil - // default manage path has a survey; answer it, then let the survey sheet finish dismissing - // so the deferred follow-up action runs + // 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) - await vm.answerSurvey(optionId: "dont_use") - #expect(vm.sheet == nil) - await vm.sheetDidDismiss() + #expect(vm.pendingSurvey == nil) #expect(vm.sheet == .safari(url)) } From 64f246450d413af5d5abca9810a812f270c4001a Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 13:27:18 -0500 Subject: [PATCH 39/42] feat(customer-center): show prices for web purchases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscription bought through Stripe or Paddle rendered with no price and its raw product identifier as the title, because `products(for:)` only ever consulted StoreKit — which knows nothing about a web product. The price was already reachable: `/v1/products` returns it, the SDK already decodes that response as `SuperwallProduct`, and `APIStoreProduct` already adapts one into a `StoreProduct`. Nothing was asking. `LiveProductsProvider` now falls back to the catalogue for any identifier StoreKit didn't resolve, so those cards show a price and a renewal line that quotes it. Failure is advisory: if the catalogue can't be reached the cards still render, just without a price, and it's logged under the `customerCenter` scope. Titles still fall back to the identifier. `/v1/products` returns no display name — the internal API has `productName`, the public one doesn't — so "Pro Monthly" instead of "web_pro_monthly" needs a field added to that payload. Pinned in a test so it's visible rather than folklore. Adds `StoreProduct.init(catalogProduct:)`, which is the existing `testProduct:` initializer under a name that doesn't imply test mode at this call site. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../CustomerCenterDependencies.swift | 30 ++++- .../Products/StoreProduct/StoreProduct.swift | 6 + SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Logic/WebProductPricingTests.swift | 109 ++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 31dcd143ae..90a331fd11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. Titles still fall back to the product identifier, since the catalogue doesn't yet return a display name. - 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 shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 77541f750e..ad9ec5ebde 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -111,10 +111,36 @@ final class LiveCustomerInfoProvider: CustomerCenterCustomerInfoProviding { } @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) - return Dictionary(uniqueKeysWithValues: products.map { ($0.productIdentifier, ProductDisplayInfo($0)) }) + 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 { + let response = try await container.network.getSuperwallProducts() + for product in response.data where missing.contains(product.identifier) { + let entitlements = Set(product.entitlements.map { Entitlement(id: $0.identifier) }) + let apiProduct = APIStoreProduct(superwallProduct: product, entitlements: entitlements) + let storeProduct = StoreProduct(catalogProduct: apiProduct) + resolved[product.identifier] = ProductDisplayInfo(storeProduct) + } + } 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 } } @available(iOS 15.0, *) @@ -163,7 +189,7 @@ extension CustomerCenterDependencies { static func live(container: DependencyContainer, configuration: CustomerCenterConfiguration) -> CustomerCenterDependencies { CustomerCenterDependencies( customerInfo: LiveCustomerInfoProvider(), - products: LiveProductsProvider(), + products: LiveProductsProvider(container: container), restore: LiveRestorer(), urlOpener: LiveURLOpener(), tracker: LiveEventTracker(), 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 17868cb4fc..d62a48e44c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -307,6 +307,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 */; }; @@ -886,6 +887,7 @@ 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 = ""; }; @@ -2032,6 +2034,7 @@ 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, + 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */, C262ABB80CCC8266542F26C5 /* WebSubscriptionPathTests.swift */, ); path = Logic; @@ -3707,6 +3710,7 @@ 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; diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift new file mode 100644 index 0000000000..85a85afc13 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -0,0 +1,109 @@ +// +// 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") throws -> SuperwallProduct { + let json = """ + { + "object": "product", + "identifier": "web_pro_monthly", + "platform": "stripe", + "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") + } + + /// Before this, a web subscription rendered with the raw product identifier as its title and no + /// price at all, because `products(for:)` only ever consulted StoreKit. + @Test("the card shows a price rather than a bare identifier", arguments: [199, 999, 7999]) + func cardShowsPrice(amountInCents: Int) throws { + let product = try decodeProduct(amountInCents: amountInCents) + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct) + + let subscription = 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 + ) + let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) + let presentations = builder.build( + customerInfo: CustomerInfo(subscriptions: [subscription], 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") + // Still the raw identifier: `/v1/products` returns no display name, so `APIStoreProduct` + // falls back to the id. The price is the half we can fix from the client; showing + // "Pro Monthly" instead of "web_pro_monthly" needs a name on that payload. + #expect(card.title == "web_pro_monthly") + } + + @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) + } +} From 1ef95b42a8efb7afb2297aa1a29f9cecc82a4d46 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 14:11:05 -0500 Subject: [PATCH 40/42] feat(customer-center): read a title out of the product identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/v1/products` carries no display name, so a web subscription's card was headed `web_pro_monthly`. Rather than wait on the payload, derive something readable from the identifier: split on separators and camel case, drop a leading reverse-DNS component, and capitalise — `web_pro_monthly` becomes "Web Pro Monthly", `com.acme.pro_monthly` becomes "Acme Pro Monthly". Acronyms and years are left as written, and anything that tidies to nothing falls back to the raw identifier rather than an empty row. Applied in `ProductDisplayInfo` where the identifier was already the fallback, not in the live products provider where it started out. That covers App Store products with an empty display name too, and — more to the point — puts it somewhere a test can reach, which the first attempt didn't. Explicitly a stopgap: the moment the payload carries a real name, the real name wins. Currency needed no change. `APIStoreProduct` formats with `currencyCode ?? "USD"` and the endpoint sends "usd", so web prices already render as dollars. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../Logic/ProductTitleFormatter.swift | 76 +++++++++++++++++++ .../CustomerCenterDependencies.swift | 4 +- SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../Logic/ProductTitleFormatterTests.swift | 37 +++++++++ .../Logic/WebProductPricingTests.swift | 7 +- .../CustomerCenterDependenciesTests.swift | 6 +- 7 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a331fd11..3107f3c8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. Titles still fall back to the product identifier, since the catalogue doesn't yet return a display name. +- 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. Titles are derived from the product identifier (`web_pro_monthly` shows as "Web Pro Monthly") until the catalogue returns a display name. - 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 shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift b/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift new file mode 100644 index 0000000000..fbd28afb4a --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift @@ -0,0 +1,76 @@ +// +// ProductTitleFormatter.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Foundation + +/// Turns a product identifier into something readable, for products that arrive without a name. +/// +/// A stopgap, not a naming scheme. Web products come from `/v1/products`, which returns no display +/// name, so a Stripe subscription would otherwise show up on a customer's screen as +/// `web_pro_monthly`. Guessing at "Web Pro Monthly" is better than that, but it is still a guess — +/// the moment the payload carries a real name, that name wins and this stops being used. +enum ProductTitleFormatter { + /// Reverse-DNS identifiers are common (`com.acme.pro.monthly`), and the leading component is + /// never part of a name anyone wants to read. + private static let reverseDomainPrefixes: Set = ["com", "io", "co", "net", "org", "app"] + + static func displayTitle(forIdentifier identifier: String) -> String { + var components = identifier + .split { $0 == "." || $0 == "_" || $0 == "-" } + .map(String.init) + + if components.count > 2, + let first = components.first, + reverseDomainPrefixes.contains(first.lowercased()) { + components.removeFirst() + } + + let words = components + .flatMap(splitCamelCase) + .map(capitalizeLeadingLetter) + .filter { !$0.isEmpty } + + // Nothing usable came out — an identifier that's all separators, say. The raw value is a + // poor title but it's at least the truth. + return words.isEmpty ? identifier : words.joined(separator: " ") + } + + /// `proMonthly` → `["pro", "Monthly"]`. Breaks before an uppercase letter that follows a + /// lowercase one or a digit, which leaves acronyms like `SWPro` intact rather than shattering + /// them into single letters. + private static func splitCamelCase(_ word: String) -> [String] { + var results: [String] = [] + var current = "" + var previous: Character? + + for character in word { + if character.isUppercase, + let previous, + previous.isLowercase || previous.isNumber, + !current.isEmpty { + results.append(current) + current = "" + } + current.append(character) + previous = character + } + if !current.isEmpty { + results.append(current) + } + return results + } + + /// Leaves a word that's already uppercase alone — `PRO` shouldn't become `Pro`, and a version + /// or year like `2024` has no letter to raise. + private static func capitalizeLeadingLetter(_ word: String) -> String { + guard let first = word.first else { return word } + if word.uppercased() == word { + return word + } + return first.uppercased() + word.dropFirst() + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index ad9ec5ebde..7961f30c39 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -76,7 +76,9 @@ enum WebManagementURLResolver { extension ProductDisplayInfo { init(_ product: StoreProduct) { - var title = product.productIdentifier + // No store gave us a name, so tidy the identifier rather than showing it raw. Applies to web + // products (whose payload carries no name at all) and to any store product with an empty one. + var title = ProductTitleFormatter.displayTitle(forIdentifier: product.productIdentifier) if #available(iOS 15.0, *), let name = product.sk2Product?.displayName, !name.isEmpty { title = name } else if let name = product.sk1Product?.localizedTitle, !name.isEmpty { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d62a48e44c..0436be748e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -242,6 +242,7 @@ 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 */; }; + 667C422164493EE3C4FD9CBB /* ProductTitleFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.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 */; }; @@ -607,6 +608,7 @@ F605AA51AB24B564D3A21B07 /* Paywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E6981E6A6574EE72B65A9E /* Paywall.swift */; }; F60F60B64FAB0670C87F43CA /* NetworkErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF59C083B597BBF7C8F8503F /* NetworkErrorTests.swift */; }; F61541FC6670E0667A96FE44 /* ArchiveManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D8461640AE665CF5A54016 /* ArchiveManifest.swift */; }; + F71685E4FF4C24B680162FE8 /* ProductTitleFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */; }; F75F5E1D503B9391ADD812EC /* PKCS7.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C2B369C690AAB9C085E2E9 /* PKCS7.swift */; }; F79C378E19116B9312AE5873 /* ASN1Decoder+Unboxing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70FC86C1189200C486627EAD /* ASN1Decoder+Unboxing.swift */; }; F7CDAF5068A17C1BFC254041 /* UIApplication+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E4503C39D6B4BFEB0FE624 /* UIApplication+Shared.swift */; }; @@ -673,6 +675,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 = ""; }; + 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTitleFormatterTests.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 = ""; }; @@ -767,6 +770,7 @@ 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 = ""; }; + 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTitleFormatter.swift; 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 = ""; }; @@ -2032,6 +2036,7 @@ 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */, 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, + 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */, @@ -2207,6 +2212,7 @@ 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */, 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */, + 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.swift */, 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, ); @@ -3672,6 +3678,7 @@ 3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */, 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */, 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, + F71685E4FF4C24B680162FE8 /* ProductTitleFormatterTests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */, @@ -4006,6 +4013,7 @@ 6D60D1CC06D717C764BDE181 /* ProductPurchaserSK2.swift in Sources */, 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */, 9A802A666FD3BEE9B246EF4B /* ProductTemplate.swift in Sources */, + 667C422164493EE3C4FD9CBB /* ProductTitleFormatter.swift in Sources */, B456ED8E41AD35A0EF5C295F /* ProductVariable.swift in Sources */, 061A6342D61F14BD286F202C /* ProductsFetcherSK1.swift in Sources */, 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift new file mode 100644 index 0000000000..39129bdece --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift @@ -0,0 +1,37 @@ +// +// ProductTitleFormatterTests.swift +// +// +// Created by Jordan Morgan on 26/08/2026. +// + +import Testing +@testable import SuperwallKit + +@Suite("Product title formatting") +struct ProductTitleFormatterTests { + @Test("tidies identifiers into something readable", arguments: [ + ("web_pro_monthly", "Web Pro Monthly"), + ("pro-annual", "Pro Annual"), + ("pro.monthly", "Pro Monthly"), + ("proMonthly", "Pro Monthly"), + ("pro", "Pro"), + // Reverse-DNS is common and its leading component is never part of a readable name. + ("com.acme.pro_monthly", "Acme Pro Monthly"), + ("io.acme.lifetime", "Acme Lifetime"), + // Two components only — nothing is dropped, since "com.pro" has no company segment to spare. + ("com.pro", "Com Pro"), + // Acronyms and years survive as written. + ("SW_PRO_2024", "SW PRO 2024"), + ("acme_PRO_yearly", "Acme PRO Yearly") + ]) + func tidiesIdentifiers(identifier: String, expected: String) { + #expect(ProductTitleFormatter.displayTitle(forIdentifier: identifier) == expected) + } + + /// A title is cosmetic; it must never end up empty and leave a blank row. + @Test("falls back to the identifier when there's nothing to tidy", arguments: ["", "...", "___"]) + func fallsBackToTheIdentifier(identifier: String) { + #expect(ProductTitleFormatter.displayTitle(forIdentifier: identifier) == identifier) + } +} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift index 85a85afc13..04c54663ea 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -80,10 +80,9 @@ struct WebProductPricingTests { #expect(card.priceLine != nil) #expect(card.statusLine.contains(display.localizedPrice ?? "!"), "the renewal line quotes the price") - // Still the raw identifier: `/v1/products` returns no display name, so `APIStoreProduct` - // falls back to the id. The price is the half we can fix from the client; showing - // "Pro Monthly" instead of "web_pro_monthly" needs a name on that payload. - #expect(card.title == "web_pro_monthly") + // `/v1/products` returns no display name, so the identifier is tidied into something + // readable rather than shown raw. Replaced by the real name once the payload carries one. + #expect(card.title == "Web Pro Monthly") } @Test("a product with no price still renders, just without one") diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift index 84e7dc141f..6f5e01a54f 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift @@ -43,12 +43,14 @@ struct CustomerCenterDependenciesTests { #expect(info.isAutoRenewable == nil) } - @Test("ProductDisplayInfo init: title falls back to the product identifier when the sk1 title is empty") + @Test("ProductDisplayInfo init: title falls back to a tidied identifier 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 == "monthly") + // Previously the raw identifier. A card headed "monthly" reads like a bug to a customer, and + // web products have no name at all to fall back on — see `ProductTitleFormatter`. + #expect(info.title == "Monthly") } } From 83bd0e4fd422d1da39916baf694ce2b735e29985 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 27 Aug 2026 15:14:30 -0500 Subject: [PATCH 41/42] refactor(customer-center): read a web product's name instead of inventing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the identifier-tidying transform with a `name` field on `SuperwallProduct`, threaded into `ProductDisplayInfo`. It's `nil` today — `/v1/products` doesn't return a name — so web products show their identifier, and they start showing a real name the moment the payload carries one, with no further change here. The transform was wrong on both ends. Its input is usually composed: web2 forces identifiers like `live:price_123:no-trial` for Stripe apps, which tidied into "Live Price 123 No Trial" — a plausible-looking product name that is entirely fiction, and worse than an obviously machine-generated string. And its output aimed at the wrong thing: the real Stripe name is the Product name, shared across a product's monthly and annual prices, so it reads "Pro" rather than "Pro Monthly". Google Play has the same shape. Thanks to the reviewer who caught the composed-identifier case. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../Logic/ProductTitleFormatter.swift | 76 ------------------- .../CustomerCenterDependencies.swift | 13 ++-- .../Network/V2ProductsResponse.swift | 8 ++ SuperwallKit.xcodeproj/project.pbxproj | 8 -- .../Logic/ProductTitleFormatterTests.swift | 37 --------- .../Logic/WebProductPricingTests.swift | 29 ++++++- .../CustomerCenterDependenciesTests.swift | 6 +- 8 files changed, 44 insertions(+), 135 deletions(-) delete mode 100644 Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift delete mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3107f3c8af..d6061099bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 browse purchase history. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`). 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. Titles are derived from the product identifier (`web_pro_monthly` shows as "Web Pro Monthly") until the catalogue returns a display name. +- 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. Titles still show the product identifier, since the catalogue doesn't return a display name yet; the SDK reads one as soon as it does. - 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 shows a back button instead of a close button and takes over the navigation bar while it's on screen, so its own drill-downs keep working and only one bar is ever visible. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift b/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift deleted file mode 100644 index fbd28afb4a..0000000000 --- a/Sources/SuperwallKit/CustomerCenter/Logic/ProductTitleFormatter.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// ProductTitleFormatter.swift -// -// -// Created by Jordan Morgan on 26/08/2026. -// - -import Foundation - -/// Turns a product identifier into something readable, for products that arrive without a name. -/// -/// A stopgap, not a naming scheme. Web products come from `/v1/products`, which returns no display -/// name, so a Stripe subscription would otherwise show up on a customer's screen as -/// `web_pro_monthly`. Guessing at "Web Pro Monthly" is better than that, but it is still a guess — -/// the moment the payload carries a real name, that name wins and this stops being used. -enum ProductTitleFormatter { - /// Reverse-DNS identifiers are common (`com.acme.pro.monthly`), and the leading component is - /// never part of a name anyone wants to read. - private static let reverseDomainPrefixes: Set = ["com", "io", "co", "net", "org", "app"] - - static func displayTitle(forIdentifier identifier: String) -> String { - var components = identifier - .split { $0 == "." || $0 == "_" || $0 == "-" } - .map(String.init) - - if components.count > 2, - let first = components.first, - reverseDomainPrefixes.contains(first.lowercased()) { - components.removeFirst() - } - - let words = components - .flatMap(splitCamelCase) - .map(capitalizeLeadingLetter) - .filter { !$0.isEmpty } - - // Nothing usable came out — an identifier that's all separators, say. The raw value is a - // poor title but it's at least the truth. - return words.isEmpty ? identifier : words.joined(separator: " ") - } - - /// `proMonthly` → `["pro", "Monthly"]`. Breaks before an uppercase letter that follows a - /// lowercase one or a digit, which leaves acronyms like `SWPro` intact rather than shattering - /// them into single letters. - private static func splitCamelCase(_ word: String) -> [String] { - var results: [String] = [] - var current = "" - var previous: Character? - - for character in word { - if character.isUppercase, - let previous, - previous.isLowercase || previous.isNumber, - !current.isEmpty { - results.append(current) - current = "" - } - current.append(character) - previous = character - } - if !current.isEmpty { - results.append(current) - } - return results - } - - /// Leaves a word that's already uppercase alone — `PRO` shouldn't become `Pro`, and a version - /// or year like `2024` has no letter to raise. - private static func capitalizeLeadingLetter(_ word: String) -> String { - guard let first = word.first else { return word } - if word.uppercased() == word { - return word - } - return first.uppercased() + word.dropFirst() - } -} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 7961f30c39..bea7eab640 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -75,15 +75,18 @@ enum WebManagementURLResolver { } extension ProductDisplayInfo { - init(_ product: StoreProduct) { - // No store gave us a name, so tidy the identifier rather than showing it raw. Applies to web - // products (whose payload carries no name at all) and to any store product with an empty one. - var title = ProductTitleFormatter.displayTitle(forIdentifier: product.productIdentifier) + /// - 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 title = product.productIdentifier if #available(iOS 15.0, *), let name = product.sk2Product?.displayName, !name.isEmpty { title = name } else if let name = product.sk1Product?.localizedTitle, !name.isEmpty { title = name } + if let name, !name.isEmpty { + title = name + } var isAutoRenewable: Bool? if #available(iOS 15.0, *), let type = product.sk2Product?.type { isAutoRenewable = type == .autoRenewable @@ -131,7 +134,7 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { let entitlements = Set(product.entitlements.map { Entitlement(id: $0.identifier) }) let apiProduct = APIStoreProduct(superwallProduct: product, entitlements: entitlements) let storeProduct = StoreProduct(catalogProduct: apiProduct) - resolved[product.identifier] = ProductDisplayInfo(storeProduct) + resolved[product.identifier] = ProductDisplayInfo(storeProduct, name: product.name) } } catch { // Advisory: the cards still render, just without a price. 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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 0436be748e..d62a48e44c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -242,7 +242,6 @@ 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 */; }; - 667C422164493EE3C4FD9CBB /* ProductTitleFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.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 */; }; @@ -608,7 +607,6 @@ F605AA51AB24B564D3A21B07 /* Paywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E6981E6A6574EE72B65A9E /* Paywall.swift */; }; F60F60B64FAB0670C87F43CA /* NetworkErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF59C083B597BBF7C8F8503F /* NetworkErrorTests.swift */; }; F61541FC6670E0667A96FE44 /* ArchiveManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25D8461640AE665CF5A54016 /* ArchiveManifest.swift */; }; - F71685E4FF4C24B680162FE8 /* ProductTitleFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */; }; F75F5E1D503B9391ADD812EC /* PKCS7.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C2B369C690AAB9C085E2E9 /* PKCS7.swift */; }; F79C378E19116B9312AE5873 /* ASN1Decoder+Unboxing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70FC86C1189200C486627EAD /* ASN1Decoder+Unboxing.swift */; }; F7CDAF5068A17C1BFC254041 /* UIApplication+Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E4503C39D6B4BFEB0FE624 /* UIApplication+Shared.swift */; }; @@ -675,7 +673,6 @@ 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 = ""; }; - 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTitleFormatterTests.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 = ""; }; @@ -770,7 +767,6 @@ 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 = ""; }; - 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductTitleFormatter.swift; 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 = ""; }; @@ -2036,7 +2032,6 @@ 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */, 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, - 0900142A7B6A6082C633113F /* ProductTitleFormatterTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, 64197E12432C67204F2FBBCF /* WebProductPricingTests.swift */, @@ -2212,7 +2207,6 @@ 5C3EFD2725CAE7F5046D386F /* AppStoreVersionLookup.swift */, 120D7D604E496BA935989AEA /* AppVersionComparator.swift */, F5BEBF6DCB345383C9CE5A97 /* CustomerCenterPathResolver.swift */, - 2BC93F257718F052C9BF8E4F /* ProductTitleFormatter.swift */, 97DCDCDFEB2442B007C38E7F /* PurchasePresentationBuilder.swift */, 5AC35B7D7641BEB17798C199 /* SupportEmailComposer.swift */, ); @@ -3678,7 +3672,6 @@ 3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */, 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */, 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, - F71685E4FF4C24B680162FE8 /* ProductTitleFormatterTests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */, @@ -4013,7 +4006,6 @@ 6D60D1CC06D717C764BDE181 /* ProductPurchaserSK2.swift in Sources */, 8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */, 9A802A666FD3BEE9B246EF4B /* ProductTemplate.swift in Sources */, - 667C422164493EE3C4FD9CBB /* ProductTitleFormatter.swift in Sources */, B456ED8E41AD35A0EF5C295F /* ProductVariable.swift in Sources */, 061A6342D61F14BD286F202C /* ProductsFetcherSK1.swift in Sources */, 4A4E788046CD308F465B37BF /* ProductsFetcherSK2.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift deleted file mode 100644 index 39129bdece..0000000000 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/ProductTitleFormatterTests.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// ProductTitleFormatterTests.swift -// -// -// Created by Jordan Morgan on 26/08/2026. -// - -import Testing -@testable import SuperwallKit - -@Suite("Product title formatting") -struct ProductTitleFormatterTests { - @Test("tidies identifiers into something readable", arguments: [ - ("web_pro_monthly", "Web Pro Monthly"), - ("pro-annual", "Pro Annual"), - ("pro.monthly", "Pro Monthly"), - ("proMonthly", "Pro Monthly"), - ("pro", "Pro"), - // Reverse-DNS is common and its leading component is never part of a readable name. - ("com.acme.pro_monthly", "Acme Pro Monthly"), - ("io.acme.lifetime", "Acme Lifetime"), - // Two components only — nothing is dropped, since "com.pro" has no company segment to spare. - ("com.pro", "Com Pro"), - // Acronyms and years survive as written. - ("SW_PRO_2024", "SW PRO 2024"), - ("acme_PRO_yearly", "Acme PRO Yearly") - ]) - func tidiesIdentifiers(identifier: String, expected: String) { - #expect(ProductTitleFormatter.displayTitle(forIdentifier: identifier) == expected) - } - - /// A title is cosmetic; it must never end up empty and leave a blank row. - @Test("falls back to the identifier when there's nothing to tidy", arguments: ["", "...", "___"]) - func fallsBackToTheIdentifier(identifier: String) { - #expect(ProductTitleFormatter.displayTitle(forIdentifier: identifier) == identifier) - } -} diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift index 04c54663ea..8b2d806439 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -13,11 +13,17 @@ import Foundation 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") throws -> SuperwallProduct { + private func decodeProduct( + amountInCents: Int, + currency: String = "USD", + name: String? = nil + ) throws -> SuperwallProduct { + let nameField = name.map { "\"name\": \"\($0)\"," } ?? "" let json = """ { "object": "product", "identifier": "web_pro_monthly", + \(nameField) "platform": "stripe", "price": { "amount": \(amountInCents), "currency": "\(currency)" }, "subscription": { @@ -80,9 +86,24 @@ struct WebProductPricingTests { #expect(card.priceLine != nil) #expect(card.statusLine.contains(display.localizedPrice ?? "!"), "the renewal line quotes the price") - // `/v1/products` returns no display name, so the identifier is tidied into something - // readable rather than shown raw. Replaced by the real name once the payload carries one. - #expect(card.title == "Web Pro Monthly") + // No name in the payload today, so the identifier stands in. Deliberately not prettified: + // a composed identifier like `live:price_123:no-trial` would tidy into a plausible-looking + // product name that is pure fiction, and the real Stripe name is per-product anyway + // ("Pro"), not per-price ("Pro Monthly"). + #expect(card.title == "web_pro_monthly") + } + + /// The field the backend hasn't shipped yet. Once `/v1/products` returns a name, it's used + /// with no further change on this side — this test is what proves that wiring works today. + @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 == "web_pro_monthly", "no name given, no name used") } @Test("a product with no price still renders, just without one") diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift index 6f5e01a54f..84e7dc141f 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterDependenciesTests.swift @@ -43,14 +43,12 @@ struct CustomerCenterDependenciesTests { #expect(info.isAutoRenewable == nil) } - @Test("ProductDisplayInfo init: title falls back to a tidied identifier when the sk1 title is empty") + @Test("ProductDisplayInfo init: title falls back to the product identifier when the sk1 title is empty") func productDisplayInfoFromSK1WithoutTitle() { let sk1 = MockSkProduct(productIdentifier: "monthly") let storeProduct = StoreProduct(sk1Product: sk1, entitlements: []) let info = ProductDisplayInfo(storeProduct) - // Previously the raw identifier. A card headed "monthly" reads like a bug to a customer, and - // web products have no name at all to fall back on — see `ProductTitleFormatter`. - #expect(info.title == "Monthly") + #expect(info.title == "monthly") } } From d8a34708c15d7ca7a0af20da3cb0b919355102ba Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 08:31:38 -0500 Subject: [PATCH 42/42] fix(customer-center): address review of the update check and web pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five behaviour bugs from review. The TestFlight guard didn't work on iOS 15. `ReceiptManager.isSandboxEnvironment` is only assigned inside an `#available(iOS 16.0, *)` branch, so below that it stays nil, `?? false`, and the guard never fires — the exact users it exists for would have been told to "update" to an older App Store build. Now goes through `DeviceHelper`, which falls back to the simulator flag and the receipt URL and accounts for test mode. The catalogue fallback filled every identifier StoreKit didn't return, not just web ones — and `products(for:)` swallows failures, so an offline StoreKit would have quoted the dashboard's storefront price, formatted en_US, for App Store subscriptions. Restricted to non-iOS platforms. A comped entitlement has no transaction behind it, so its store is nil and the builder reports `.superwall`, which reads as a web store. That customer was shown "Manage subscription" and told to find a link in a receipt they were never sent. Entitlement-only purchases now get the page only when one exists, and never the receipt blurb. The catalogue fetch sat on the path out of `.loading` with the endpoint's defaults — six retries, exponential backoff, no timeout — so a failing backend could hold the spinner for minutes over prices that are a nicety. Bounded to five seconds. `customerCenterOpen` was tracked behind the App Store lookup, so closing the screen mid-request could emit close before open. The lookup now runs after tracking, which also restores the "render first, banner later" behaviour it was meant to have. Splits the manage-subscription resolution into its own function; the added branch pushed the resolver's switch past the complexity limit. Co-Authored-By: Claude Opus 5 --- .../Logic/CustomerCenterPathResolver.swift | 39 ++++++++++++------ .../CustomerCenterDependencies.swift | 41 +++++++++++++++++-- .../ViewModel/CustomerCenterViewModel.swift | 7 ++-- .../Logic/WebSubscriptionPathTests.swift | 26 ++++++++++++ 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift index 82dd9641bc..c740dfd42a 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -64,6 +64,32 @@ enum CustomerCenterPathResolver { } } + /// 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 } + + // An entitlement with no transaction behind it — comped, or granted by hand — has a nil store + // that the builder reports as `.superwall`, which lands here. There is no subscription to + // manage, so offer the page only if one exists and never claim a receipt was sent. + if case .entitlementOnly = purchase.kind { + 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 @@ -92,18 +118,7 @@ enum CustomerCenterPathResolver { return .custom(identifier) case .manageSubscription: - guard let purchase else { return nil } - if isAppStore { - 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) - } - if isWebStore { - return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } ?? .webManageUnavailable - } - return nil + return manageSubscriptionDestination(context: context) case .refund(let window): guard isAppStore, let sub, !sub.isRevoked, sub.offerType != .trial, !context.isFamilyShared else { return nil } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index bea7eab640..3176fd0c4f 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -129,8 +129,18 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { let missing = ids.subtracting(resolved.keys) guard !missing.isEmpty else { return resolved } do { - let response = try await container.network.getSuperwallProducts() - for product in response.data where missing.contains(product.identifier) { + // 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 withCatalogueTimeout { + try await container.network.getSuperwallProducts() + } + // `missing` 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 restrict this to products StoreKit was never going to resolve. + for product in response.data + 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) @@ -147,6 +157,26 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { } return resolved } + + /// How long the catalogue gets before the screen gives up on prices and renders without them. + private static let catalogueTimeout: TimeInterval = 5 + + private 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 + } + } } @available(iOS 15.0, *) struct LiveRestorer: CustomerCenterRestoring { @@ -172,7 +202,12 @@ struct LiveEnvironment: CustomerCenterEnvironmentProviding { var deviceModel: String { UIDevice.current.model } var sdkVersion: String { SuperwallKit.sdkVersion } var userId: String { Superwall.shared.userId } - var isSandbox: Bool { ReceiptManager.isSandboxEnvironment ?? false } + /// 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)") } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 1914aadea3..e9fd438f58 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -108,9 +108,6 @@ final class CustomerCenterViewModel: ObservableObject { func load() async { let info = await dependencies.customerInfo.fetchCustomerInfo() await apply(customerInfo: info, refetchProducts: true) - // Deliberately after the first `apply`: the screen renders straight away rather than waiting - // on a network round trip, and the banner animates in afterwards if there's something to say. - await refreshAppStoreVersion() if !hasTrackedOpen { hasTrackedOpen = true await dependencies.tracker.track( @@ -120,6 +117,10 @@ final class CustomerCenterViewModel: ObservableObject { ) ) } + // 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. Tracking open behind it would let a user + // who closes the screen mid-lookup emit close before open. + await refreshAppStoreVersion() } private func apply(customerInfo: CustomerInfo, refetchProducts: Bool) async { diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift index 8bfbc87a29..5d4ddd3e19 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift @@ -111,6 +111,32 @@ struct WebSubscriptionPathTests { #expect(viewModel.sheet == .safari(managementURL)) } + /// An entitlement with no transaction behind it — comped, or granted by hand — has a nil store + /// that the builder reports 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. + @available(iOS 15.0, *) + @Test("a comped entitlement isn't told to check a receipt it never had") + func compedEntitlementGetsNoReceiptBlurb() async { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo( + subscriptions: [], + nonSubscriptions: [], + entitlements: [Entitlement(id: "pro")] + ), + environment: EnvironmentMock(webManagementURL: nil) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + + let purchase = viewModel.purchases.first + let manage = viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } + #expect(manage == nil, "nothing to manage, so no row at all") + } + // 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