From 4a76c2c693e3456f87032cf548bc9bd854270285 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 20 Aug 2026 13:17:02 -0500 Subject: [PATCH 01/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] =?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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] =?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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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/64] 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 From 1b1d7b3788f61a789a5f2da01cbda89fb1825da6 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 12:09:08 -0500 Subject: [PATCH 43/64] refactor(customer-center): stop touching the host's navigation bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pushed Customer Center used to hide the host's navigation bar, supply its own in place of it, install a gesture delegate to keep swipe-back alive, and restore the lot on the way out. That was the wrong trade: an SDK has no business mutating a host's chrome, and every one of those moving parts was a glitch waiting for a host whose bar is styled or whose swipe-back is deliberately off. The constraint that drove it was real — SwiftUI's `NavigationLink` needs a SwiftUI navigation ancestor, and a `UINavigationController` isn't one, so dropping our own `NavigationView` killed the drill-downs. The fix is to push them properly rather than to take over the bar: `CustomerCenterNavigating` pushes purchase history and purchase detail onto the host's stack as their own hosting controllers. Their bar, their back button, their appearance, untouched. Each pushed destination is a fresh SwiftUI root, so the Customer Center's strings, theme and navigator are reapplied to it, and it carries the sheet modifiers itself. Only the topmost surface presents: every screen still in the stack applies those modifiers, so without a check they would all race to present the same sheet. `pushDepth` on the view model and a matching environment value settle which one wins — gating the bindings rather than the modifiers, so the view tree stays stable. Modal is unchanged: it still supplies its own navigation and close button, because there is no host navigation to defer to. Removes `showsBackButton`/`onBack` from `CustomerCenterNavigationOptions` and the `customer_center_back` string from all 41 locales, none of which have a purpose now. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- Examples/Basic/Basic/HomeView.swift | 5 +- .../Basic/Superwall_Basic-Products.storekit | 29 +++-- .../UIKit/CustomerCenterPushNavigator.swift | 69 +++++++++++ .../UIKit/CustomerCenterViewController.swift | 100 ++++------------ .../ViewModel/CustomerCenterViewModel.swift | 3 + .../Views/CustomerCenterNavigator.swift | 83 +++++++++++++ .../Views/CustomerCenterSheets.swift | 7 ++ .../Views/CustomerCenterStrings+English.swift | 1 - .../Views/CustomerCenterView.swift | 57 +++------ .../Views/ManagementScreenView.swift | 6 +- .../Views/PurchaseHistoryView.swift | 2 +- .../Documentation.docc/CustomerCenter.md | 11 +- .../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 - SuperwallKit.xcodeproj/project.pbxproj | 8 ++ .../CustomerCenterViewControllerTests.swift | 113 ++---------------- 56 files changed, 253 insertions(+), 284 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift create mode 100644 Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d6061099bf..301438e682 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - 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 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. +- `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it renders into your navigation bar without modifying it, and its own screens are pushed onto your stack as further view controllers. ### Fixes diff --git a/Examples/Basic/Basic/HomeView.swift b/Examples/Basic/Basic/HomeView.swift index f8bc3ada8c..0e3035f8ae 100644 --- a/Examples/Basic/Basic/HomeView.swift +++ b/Examples/Basic/Basic/HomeView.swift @@ -19,10 +19,7 @@ struct HomeView: View { init(isLoggedIn: Binding) { _isLoggedIn = isLoggedIn - UINavigationBar.appearance().titleTextAttributes = [ - .foregroundColor: UIColor.white, - .font: UIFont.rubikBold(.five) - ] + } var firstName: String? { diff --git a/Examples/Basic/Basic/Superwall_Basic-Products.storekit b/Examples/Basic/Basic/Superwall_Basic-Products.storekit index ee8085e579..f378e152a4 100644 --- a/Examples/Basic/Basic/Superwall_Basic-Products.storekit +++ b/Examples/Basic/Basic/Superwall_Basic-Products.storekit @@ -17,55 +17,51 @@ ], "settings" : { + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, "_compatibilityTimeRate" : { "3" : 6 }, + "_disableDialogs" : false, "_failTransactionsEnabled" : false, "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, "_storefront" : "USA", "_storeKitErrors" : [ { - "current" : null, "enabled" : false, "name" : "Load Products" }, { - "current" : null, "enabled" : false, "name" : "Purchase" }, { - "current" : null, "enabled" : false, "name" : "Verification" }, { - "current" : null, "enabled" : false, "name" : "App Store Sync" }, { - "current" : null, "enabled" : false, "name" : "Subscription Status" }, { - "current" : null, "enabled" : false, "name" : "App Transaction" }, { - "current" : null, "enabled" : false, "name" : "Manage Subscriptions Sheet" }, { - "current" : null, "enabled" : false, "name" : "Refund Request Sheet" }, { - "current" : null, "enabled" : false, "name" : "Offer Code Redeem Sheet" } @@ -83,6 +79,15 @@ { "adHocOffers" : [ + ], + "billingPlans" : [ + { + "billingPlanType" : "BILLED_UPFRONT", + "commitmentDisplayPrice" : "39.99", + "displayPrice" : "39.99", + "internalID" : "7ACEE564", + "isEnabled" : true + } ], "codeOffers" : [ @@ -91,7 +96,9 @@ "familyShareable" : false, "groupNumber" : 1, "internalID" : "E02B772A", - "introductoryOffer" : null, + "introductoryOffers" : [ + + ], "localizations" : [ { "description" : "Access to pro features", @@ -112,7 +119,7 @@ } ], "version" : { - "major" : 4, + "major" : 5, "minor" : 0 } } diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift new file mode 100644 index 0000000000..a511dc1f5b --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -0,0 +1,69 @@ +// +// CustomerCenterPushNavigator.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import SwiftUI +import UIKit + +/// Pushes the Customer Center's drill-downs onto the host's own navigation controller. +/// +/// Used only by ``CustomerCenterPresentationStyle/pushed``, where there's no SwiftUI navigation +/// ancestor for `NavigationLink` to use. Each destination becomes its own hosting controller, so +/// the host's navigation bar drives it — their back button, their title treatment, their +/// appearance — and nothing of theirs is modified. +@available(iOS 15.0, *) +@MainActor +final class CustomerCenterPushNavigator: CustomerCenterNavigating { + weak var presenter: UIViewController? + private let viewModel: CustomerCenterViewModel + + init(viewModel: CustomerCenterViewModel) { + self.viewModel = viewModel + } + + func push(_ destination: Destination) { + guard let navigationController = presenter?.navigationController else { return } + + // A pushed destination is a fresh SwiftUI root: nothing from the presenting hierarchy's + // environment reaches it, so the Customer Center's own values have to be reapplied. + let depth = viewModel.pushDepth + 1 + let colorScheme: ColorScheme = + presenter?.traitCollection.userInterfaceStyle == .dark ? .dark : .light + let theme = CustomerCenterTheme( + appearance: viewModel.configuration.appearance, + colorScheme: colorScheme + ) + let hosted = destination + .environment(\.customerCenterStrings, viewModel.strings) + .environment(\.customerCenterTheme, theme) + .environment(\.customerCenterNavigator, self) + .environment(\.customerCenterSurfaceDepth, depth) + .customerCenterSheets(viewModel: viewModel) + .tint(theme.accent) + + let controller = CustomerCenterPushedHostingController(rootView: hosted) + controller.onRemovedFromParent = { [weak self] in + // Back to whatever is underneath. Depth drives which surface is allowed to present sheets, + // so it has to come down again or the screen the user returns to stays mute. + self?.viewModel.pushDepth = max(0, depth - 1) + } + viewModel.pushDepth = depth + navigationController.pushViewController(controller, animated: true) + } +} + +/// A hosting controller that reports being popped, so the navigator can restore the depth. +@available(iOS 15.0, *) +private final class CustomerCenterPushedHostingController: UIHostingController { + var onRemovedFromParent: (() -> Void)? + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + if parent == nil { + onRemovedFromParent?() + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 06559bb3d4..43cc21cc88 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -14,9 +14,9 @@ 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. + /// Pushed onto a `UINavigationController` you own. Renders into your navigation bar and leaves + /// it entirely alone — your title, your back button, your appearance. Its own drill-downs are + /// pushed onto your stack as further view controllers. case pushed } @@ -41,11 +41,8 @@ public final class CustomerCenterViewController: UIHostingController 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() + /// Retains the navigator that pushes this controller's own drill-downs, in `.pushed` style. + private var pushNavigator: CustomerCenterPushNavigator? /// Whether this controller was on screen as part of a modal presentation, recorded while it /// still is. Compared against `presentingViewController` on the way out — see @@ -112,16 +109,13 @@ public final class CustomerCenterViewController: UIHostingController Bool { - // Swiping on the stack's root would leave UIKit mid-transition with nothing to pop. - 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.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index e9fd438f58..bb93cfe16c 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -26,6 +26,9 @@ final class CustomerCenterViewModel: ObservableObject { // `CustomerCenterViewModel+UpdateBanner.swift`, and `private` is file-scoped. @Published var showsUpdateBanner = false @Published private(set) var showsDuplicateBanner = false + /// How many of the Customer Center's own screens the host has pushed above the root, when it + /// owns the navigation. Only the surface at this depth presents sheets. + @Published var pushDepth = 0 let configuration: CustomerCenterConfiguration let strings: CustomerCenterStrings diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift new file mode 100644 index 0000000000..b3d45504c7 --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift @@ -0,0 +1,83 @@ +// +// CustomerCenterNavigator.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import SwiftUI + +/// Pushes one of the Customer Center's own screens — purchase history, purchase detail. +/// +/// Exists because SwiftUI's `NavigationLink` needs a SwiftUI navigation ancestor, and a +/// `UINavigationController` isn't one. When a host pushes ``CustomerCenterViewController`` onto its +/// own stack there's no such ancestor, so the drill-downs have to be pushed through UIKit instead. +/// Everywhere else — presented modally, or embedded in the host's own SwiftUI navigation — this is +/// `nil` and `NavigationLink` does the work. +@available(iOS 15.0, *) +@MainActor +protocol CustomerCenterNavigating: AnyObject { + func push(_ destination: Destination) +} + +@available(iOS 15.0, *) +private struct CustomerCenterNavigatorKey: EnvironmentKey { + static let defaultValue: CustomerCenterNavigating? = nil +} + +@available(iOS 15.0, *) +extension EnvironmentValues { + var customerCenterNavigator: CustomerCenterNavigating? { + get { self[CustomerCenterNavigatorKey.self] } + set { self[CustomerCenterNavigatorKey.self] = newValue } + } +} + +@available(iOS 15.0, *) +private struct CustomerCenterSurfaceDepthKey: EnvironmentKey { + static let defaultValue = 0 +} + +@available(iOS 15.0, *) +extension EnvironmentValues { + /// How many Customer Center screens have been pushed above the root through UIKit. Compared + /// against ``CustomerCenterViewModel/pushDepth`` so that only the topmost surface presents + /// sheets — otherwise every screen still in the stack would race to present the same one. + var customerCenterSurfaceDepth: Int { + get { self[CustomerCenterSurfaceDepthKey.self] } + set { self[CustomerCenterSurfaceDepthKey.self] = newValue } + } +} + +/// A row that drills into another Customer Center screen, by whichever mechanism the surrounding +/// navigation supports. +@available(iOS 15.0, *) +struct CustomerCenterDrillDown: View { + @Environment(\.customerCenterNavigator) private var navigator + @ViewBuilder let destination: () -> Destination + @ViewBuilder let label: () -> Label + + var body: some View { + // Branching on `navigator` is safe: it's fixed for the lifetime of the hierarchy, so this + // can't flip mid-update and tear down a modifier that was about to do something. + if let navigator { + Button { + navigator.push(destination()) + } label: { + HStack { + label() + Spacer() + // `NavigationLink` draws its own chevron; this branch has to supply one, and the row + // does push, so the chevron is telling the truth. + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + NavigationLink(destination: destination(), label: label) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index b776e157ef..94b833b7d4 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -20,6 +20,13 @@ extension View { private struct CustomerCenterSheetsModifier: ViewModifier { @ObservedObject var viewModel: CustomerCenterViewModel @Environment(\.customerCenterStrings) private var strings + @Environment(\.customerCenterSurfaceDepth) private var depth + + /// Every screen still in the stack applies this modifier, so without a check they'd all try to + /// present the same sheet. Gating the bindings rather than the modifier keeps the view tree + /// stable — swapping modifiers mid-update is what stopped the manage sheet appearing once + /// before. + private var isTopmost: Bool { depth == viewModel.pushDepth } private var isManagePresented: Binding { .init( diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 88efebb6ff..7a19897d40 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -40,7 +40,6 @@ 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 21c4b351d7..4edd86dd37 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -14,33 +14,22 @@ 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, - showsBackButton: Bool = false, - onClose: (() -> Void)? = nil, - onBack: (() -> Void)? = nil + onClose: (() -> 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. @@ -52,6 +41,8 @@ public struct CustomerCenterNavigationOptions { public struct CustomerCenterView: View { @StateObject private var viewModel: CustomerCenterViewModel private let navigationOptions: CustomerCenterNavigationOptions + /// Supplied when the host owns the navigation, so drill-downs push onto their stack. + private let navigator: CustomerCenterNavigating? @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.customerCenterCallbacks) private var callbacksBox @@ -75,6 +66,7 @@ public struct CustomerCenterView: View { ) ) self.navigationOptions = navigationOptions + navigator = nil } private static func makeConfiguredViewModel( @@ -86,9 +78,14 @@ public struct CustomerCenterView: View { return model } - init(viewModel: CustomerCenterViewModel, navigationOptions: CustomerCenterNavigationOptions) { + init( + viewModel: CustomerCenterViewModel, + navigationOptions: CustomerCenterNavigationOptions, + navigator: CustomerCenterNavigating? = nil + ) { _viewModel = StateObject(wrappedValue: viewModel) self.navigationOptions = navigationOptions + self.navigator = navigator } public var body: some View { @@ -101,6 +98,7 @@ public struct CustomerCenterView: View { } .environment(\.customerCenterStrings, viewModel.strings) .environment(\.customerCenterTheme, theme) + .environment(\.customerCenterNavigator, navigator) .task { viewModel.callbacks = Self.merged(viewModel.callbacks, callbacksBox.callbacks) await viewModel.load() @@ -134,25 +132,12 @@ public struct CustomerCenterView: View { .tint(themeAccent) } - // `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. + // `ToolbarContentBuilder`'s conditional (`if`) support needs iOS 16, so the close button is + // toggled here at the plain `@ViewBuilder` level instead, which iOS 15 supports. Branching on + // the flag is safe even though branch flips tear down modifiers: it comes from + // `navigationOptions`, fixed for the view's lifetime, so it can't flip mid-update. @ViewBuilder private var screenContent: some View { - if navigationOptions.showsBackButton { - closeConfiguredContent.toolbar { backButtonToolbarItem } - } else { - closeConfiguredContent - } - } - - @ViewBuilder - private var closeConfiguredContent: some View { if navigationOptions.showsCloseButton { coreContent.toolbar { closeButtonToolbarItem } } else { @@ -186,18 +171,6 @@ 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/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 41f6d0f0f1..7364750317 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -31,7 +31,7 @@ struct ManagementScreenView: View { if isSingle { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) } else { - NavigationLink { + CustomerCenterDrillDown { PurchaseDetailScreenView(viewModel: viewModel, purchase: purchase) } label: { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) @@ -50,8 +50,10 @@ struct ManagementScreenView: View { } if viewModel.configuration.showsPurchaseHistory { Section { - NavigationLink(strings.string("customer_center_see_all_purchases")) { + CustomerCenterDrillDown { PurchaseHistoryView(viewModel: viewModel) + } label: { + Text(strings.string("customer_center_see_all_purchases")) } .accessibilityIdentifier("customer_center.purchase_history") } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index 053bb721ae..784617c906 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift @@ -31,7 +31,7 @@ struct PurchaseHistoryView: View { if !items.isEmpty { Section(strings.string(key)) { ForEach(items) { item in - NavigationLink { + CustomerCenterDrillDown { PurchaseDetailRows(viewModel: viewModel, purchase: item) } label: { PurchaseCardView(purchase: item, refundResult: nil) diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 887e30ba0c..dd59b86a59 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -37,12 +37,11 @@ let customerCenter = CustomerCenterViewController( 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 — except while the user is drilled into the -Customer Center's own screens, where the back button takes them up one level instead. +A pushed Customer Center renders into your navigation bar and leaves it alone — your title, your +back button, your appearance, your swipe-to-go-back. It adds no close button, since your stack +already provides the way back. Its own screens, purchase history and per-purchase detail, are +pushed onto your stack as further view controllers, so they behave like any other screen you +pushed yourself. > Important: A `CustomerCenterViewController` you construct yourself is yours, and the SDK does not > track it. ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)`` will present diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index f8dfcbe1a0..c9b5486af8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 673393c760..fb41eac7b4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 fb6af74ffe..0e83549417 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 59b742a3d5..83697b6a03 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 88b819ad34..0c64a0463d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 b4c7d42757..5e55e8c42f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 b007ae6bf4..636673eac4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 b007ae6bf4..636673eac4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 b007ae6bf4..636673eac4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 c2e583aa85..9337311b7c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 4c6d2032f1..7e5b43e6e0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 e0b45922b4..036936a3e2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 88575d98e7..bea606aedd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 adaa51b629..de17fabce0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 597bea5416..86418594b8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 42571aad84..d4dce01f39 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 8e9ae3e94d..5d50b68d1b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 c7a19ac660..226e56018c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 4f5cfd57e7..af9f3626c5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 1aa9d0dd93..2d33e2e71e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 4ec7a839d1..25a94403de 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 3cdffe76c6..9129a575e8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 9d24bbf398..22aca04d32 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 73e1db92d7..0bf9402ede 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 e496c8fd6d..a28aeacacd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 857b8d6b94..0c92ca3995 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 08973efca7..603b397fad 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 61e978fc3a..926b9ead14 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 e99ab17700..cc4db1bbe2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 19ae0e2ee5..7c2947ba60 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 53e5960963..4b762bee3f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 74561c69fb..d44c3df0a0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 54476f8bb3..1b773a07d1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 aa93aa8360..0086c3bdaf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 d6c07ca2f6..b8ea720d86 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 f3598ebd30..48037aa718 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 65d81c265a..3a795647f5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 f63d2621d6..cde053d23f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 d253312f6c..aad840ce47 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 756fec073f..0a140c1473 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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 e538098526..b483200aee 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -42,7 +42,6 @@ "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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d62a48e44c..d43bdec398 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 3002A50E92B640B4E3A98662 /* SuperwallKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 04FB15C76DE3D22CB370AFDB /* SuperwallKit.framework */; }; 30113C71D033ADDF01214C75 /* PreloadingDisabled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D2598EB46E9A27E2BD5104 /* PreloadingDisabled.swift */; }; 309EC3675C7EF75050B076E7 /* ComputedPropertyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51445FD3A0C38C2B502EAF1D /* ComputedPropertyRequest.swift */; }; + 30C0236F4E6A5260BFEF59AF /* CustomerCenterPushNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */; }; 31DE588B2B4A26745C33753C /* ThrowableDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC6A60945A85646E1230C1 /* ThrowableDecodable.swift */; }; 31E937EB414F62268F6C953C /* TestModeInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3548435BDE49161E3BDFA358 /* TestModeInfoCell.swift */; }; 32A52161B29C999F06217B9A /* AppUpdateWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */; }; @@ -171,6 +172,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 */; }; + 4616C021DCB8228B75D8B591 /* CustomerCenterNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */; }; 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */; }; 480C37A4D7A8AB5EE0760BF1 /* PaywallLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC6C4D551369C55D8AFB7F96 /* PaywallLogic.swift */; }; 481903391564D2B19A9BD285 /* CustomerCenterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CF0668C27EEEF9505006818 /* CustomerCenterDelegate.swift */; }; @@ -695,6 +697,7 @@ 115132479C9C41D57C9E3BA9 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 120D7D604E496BA935989AEA /* AppVersionComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionComparator.swift; sourceTree = ""; }; 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyContainer.swift; sourceTree = ""; }; + 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterPushNavigator.swift; sourceTree = ""; }; 1528915438E6714B1F7F7BD4 /* PaywallRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestManager.swift; sourceTree = ""; }; 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyle.swift; sourceTree = ""; }; 15E6FBB3D0826827A04F87AE /* EndpointKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndpointKind.swift; sourceTree = ""; }; @@ -912,6 +915,7 @@ 6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegate.swift; sourceTree = ""; }; 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDetailsSection.swift; sourceTree = ""; }; 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTests.swift; sourceTree = ""; }; + 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterNavigator.swift; sourceTree = ""; }; 6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; }; 6BE5DAD3AB51C8C6B5AB88D2 /* GetExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetExperiment.swift; sourceTree = ""; }; 6C47C3978AA22485EC9F5D24 /* SK2StoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductDiscount.swift; sourceTree = ""; }; @@ -1339,6 +1343,7 @@ isa = PBXGroup; children = ( 632BA7AFDDD93F08252C9043 /* CustomerCenterDelegateAdapter.swift */, + 13F7CF7FEC54E1EB4DF66A2D /* CustomerCenterPushNavigator.swift */, 1EF06E9F79CA4C3BABD0D887 /* CustomerCenterViewController.swift */, ); path = UIKit; @@ -1484,6 +1489,7 @@ 6B36C6BD72D66F94DE620F16 /* AccountDetailsSection.swift */, 1D83A9FEE5901713FC693147 /* AppUpdateWarningView.swift */, 518841D661079BCD21DA7692 /* CustomerCenterEnvironment.swift */, + 6B928799A028CB182E46CB4B /* CustomerCenterNavigator.swift */, 4E4904244CA123DEE51D7E71 /* CustomerCenterSheets.swift */, 9C3A5B3F5DCF95EE9649CDA8 /* CustomerCenterStrings+English.swift */, 74230D92533298E4C0DAE83A /* CustomerCenterView.swift */, @@ -3804,7 +3810,9 @@ A3E29135312C5A933D6234C5 /* CustomerCenterDependencies.swift in Sources */, D99C565B5803B6ECE29A3D8B /* CustomerCenterEnvironment.swift in Sources */, 295EF01B171923E20329DF91 /* CustomerCenterManager.swift in Sources */, + 4616C021DCB8228B75D8B591 /* CustomerCenterNavigator.swift in Sources */, 346FAC08A7D3932CE3FAD129 /* CustomerCenterPathResolver.swift in Sources */, + 30C0236F4E6A5260BFEF59AF /* CustomerCenterPushNavigator.swift in Sources */, 9A883BA2FA1E9614B7B29EE9 /* CustomerCenterScreenState.swift in Sources */, 26250F084157D9E2556338FB /* CustomerCenterSheets.swift in Sources */, 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift index ebfb315e43..8a2bafe8db 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -190,124 +190,37 @@ struct CustomerCenterViewControllerTests { // 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. + /// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on + /// the way out. It no longer touches the bar in any style — the host's chrome is theirs. @available(iOS 15.0, *) - @Test("modal style leaves the host's navigation bar alone even on a stack") - func modalStyleLeavesTheBarAlone() { - let controller = makeController(style: .modal, delegate: nil) + @Test("neither style modifies the host's navigation bar", arguments: [ + CustomerCenterPresentationStyle.pushed, .modal + ]) + func neitherStyleTouchesTheHostBar(style: CustomerCenterPresentationStyle) { + let controller = makeController(style: style, delegate: nil) let navigation = UINavigationController(rootViewController: UIViewController()) navigation.setNavigationBarHidden(false, animated: false) let window = makeWindow(rootViewController: navigation) window.makeKeyAndVisible() + let recognizer = navigation.interactivePopGestureRecognizer + let hostDelegate = recognizer?.delegate + let hostEnabled = recognizer?.isEnabled navigation.pushViewController(controller, animated: false) spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } - #expect(!navigation.isNavigationBarHidden) + #expect(!navigation.isNavigationBarHidden, "the host's bar stays visible") + #expect(recognizer?.delegate === hostDelegate, "the pop gesture is left alone") + #expect(recognizer?.isEnabled == hostEnabled) window.isHidden = true } - /// 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 From 6ba5ddf8ac16cf9220e1fc2431d2a55a22bcb900 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 12:41:59 -0500 Subject: [PATCH 44/64] fix(customer-center): make the pushed-stack sheet gating actually work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the previous commit found the depth gate it introduced was never wired up. `isTopmost` was declared and referenced nowhere, so every screen in a pushed stack still applied ungated bindings and all of them raced to present the same sheet — the exact behaviour the mechanism existed to prevent. Every binding now consults it. The setters needed it too, not just the getters. A surface that isn't topmost could still clear `viewModel.sheet` and fire `sheetDidDismiss()`, dismissing a sheet another screen owns and running its deferred follow-up against the wrong screen. Restoring the depth on pop was order-dependent. Popping several screens at once removes them all, and UIKit doesn't document the order it calls `didMove(toParent:)` in; assigning `depth - 1` let a deeper screen's callback overwrite a shallower one and strand the depth above the surface the user had returned to, leaving it unable to present anything again. A first attempt at guarding on "still topmost" fixed one ordering and not the other — the new test caught that — so it now takes `min`, which gives the same answer whichever way round the callbacks arrive. Pushed screens also captured the colour scheme at push time, so one opened in light mode kept the light palette after a switch to dark while the root followed along. The theme is now resolved inside the destination from the environment. Drops `isShowingPushedSurface`, `pushedSurfaceCount` and the `isPushed:` parameters. Their only consumer was the gesture delegate deleted in the previous commit, and leaving a second, unused notion of navigation depth next to `pushDepth` invites the two to drift. Co-Authored-By: Claude Opus 5 --- .../UIKit/CustomerCenterPushNavigator.swift | 43 ++++++++--- .../ViewModel/CustomerCenterViewModel.swift | 21 +----- .../Views/CustomerCenterSheets.swift | 20 +++-- .../Views/ManagementScreenView.swift | 4 +- .../Views/PurchaseHistoryView.swift | 8 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../CustomerCenterSheetOwnershipTests.swift | 75 +++++++++++++++++++ 7 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift index a511dc1f5b..3fa3883920 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -30,31 +30,54 @@ final class CustomerCenterPushNavigator: CustomerCenterNavigating { // A pushed destination is a fresh SwiftUI root: nothing from the presenting hierarchy's // environment reaches it, so the Customer Center's own values have to be reapplied. let depth = viewModel.pushDepth + 1 - let colorScheme: ColorScheme = - presenter?.traitCollection.userInterfaceStyle == .dark ? .dark : .light - let theme = CustomerCenterTheme( + // The theme is resolved inside the destination rather than captured here, so it follows the + // colour scheme. Reading `traitCollection` at push time would freeze a pushed screen in + // whichever appearance was active when it opened, while the root kept tracking the change. + let hosted = CustomerCenterThemedContainer( appearance: viewModel.configuration.appearance, - colorScheme: colorScheme + content: destination ) - let hosted = destination .environment(\.customerCenterStrings, viewModel.strings) - .environment(\.customerCenterTheme, theme) .environment(\.customerCenterNavigator, self) .environment(\.customerCenterSurfaceDepth, depth) .customerCenterSheets(viewModel: viewModel) - .tint(theme.accent) let controller = CustomerCenterPushedHostingController(rootView: hosted) controller.onRemovedFromParent = { [weak self] in - // Back to whatever is underneath. Depth drives which surface is allowed to present sheets, - // so it has to come down again or the screen the user returns to stays mute. - self?.viewModel.pushDepth = max(0, depth - 1) + // Back to whatever is underneath. `min` rather than a plain assignment because popping + // several screens at once removes them all and UIKit doesn't document the order it calls + // `didMove(toParent:)` in: taking the lowest reported depth is the same answer whichever + // way round they arrive, where assigning leaves the depth stranded above the surface the + // user is actually on — and that surface then can't present anything for the rest of the + // presentation. + guard let self else { return } + self.viewModel.pushDepth = min(self.viewModel.pushDepth, depth - 1) } viewModel.pushDepth = depth navigationController.pushViewController(controller, animated: true) } } +/// Applies the Customer Center's theme to a pushed screen, recomputing it whenever the colour +/// scheme changes. A pushed destination is its own SwiftUI root, so it inherits nothing from the +/// screen that pushed it. +@available(iOS 15.0, *) +private struct CustomerCenterThemedContainer: View { + let appearance: CustomerCenterConfiguration.Appearance + let content: Content + @Environment(\.colorScheme) private var colorScheme + + private var theme: CustomerCenterTheme { + CustomerCenterTheme(appearance: appearance, colorScheme: colorScheme) + } + + var body: some View { + content + .environment(\.customerCenterTheme, theme) + .tint(theme.accent) + } +} + /// A hosting controller that reports being popped, so the navigator can restore the depth. @available(iOS 15.0, *) private final class CustomerCenterPushedHostingController: UIHostingController { diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index bb93cfe16c..8b10d1c7f9 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -69,9 +69,6 @@ final class CustomerCenterViewModel: ObservableObject { /// 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( @@ -333,27 +330,19 @@ extension CustomerCenterViewModel { /// 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) { + func surfaceDidAppear() { visibleSurfaceCount += 1 - if isPushed { - pushedSurfaceCount += 1 - } dismissDebounceTask?.cancel() dismissDebounceTask = nil } - /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear(isPushed:)``. + /// Call from the matching `onDisappear` of any surface that called ``surfaceDidAppear()``. /// When the count drops to zero, waits out a debounce before dismissing: a push/pop transition can /// briefly have both surfaces on screen or neither, so one runloop turn can't tell "navigating /// within the Customer Center" from "the Customer Center was torn down". An appearance before the /// debounce elapses cancels it. - func surfaceDidDisappear(isPushed: Bool = false) { + func surfaceDidDisappear() { 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 @@ -368,10 +357,6 @@ 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. diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index 94b833b7d4..58e152b1ee 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -30,25 +30,34 @@ private struct CustomerCenterSheetsModifier: ViewModifier { 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() } } } + get: { + guard isTopmost, case .manageSubscriptions = viewModel.sheet else { return false } + return true + }, + set: { if isTopmost, !$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 } } + get: { + guard isTopmost, case .refund = viewModel.sheet else { return false } + return true + }, + set: { if isTopmost, !$0, case .refund = viewModel.sheet { viewModel.sheet = nil } } ) } private var itemSheet: Binding { .init( get: { + guard isTopmost else { return nil } switch viewModel.sheet { case .survey, .changePlan, .safari, .noMailApp, .webManageUnavailable: return viewModel.sheet default: return nil } }, - set: { viewModel.sheet = $0 } + // Guarded like the getter: a surface that isn't topmost must not clear a sheet another + // screen owns, which would dismiss it and run its deferred follow-up on the wrong screen. + set: { if isTopmost { viewModel.sheet = $0 } } ) } private var manageGroupId: String? { @@ -65,6 +74,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { } private var onItemSheetDismiss: () -> Void { { + guard isTopmost else { return } if viewModel.pendingSurvey != nil { viewModel.cancelSurvey() } Task { await viewModel.sheetDidDismiss() } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 7364750317..bcab7ec297 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -102,7 +102,7 @@ struct PurchaseDetailScreenView: View { .listStyle(.insetGrouped) .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.surfaceDidAppear(isPushed: true) } - .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift index 784617c906..9539faeef8 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(isPushed: true) } - .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } @ViewBuilder @@ -87,8 +87,8 @@ struct PurchaseDetailRows: View { } .navigationTitle(purchase.title) .navigationBarTitleDisplayMode(.inline) - .onAppear { viewModel.surfaceDidAppear(isPushed: true) } - .onDisappear { viewModel.surfaceDidDisappear(isPushed: true) } + .onAppear { viewModel.surfaceDidAppear() } + .onDisappear { viewModel.surfaceDidDisappear() } } private func row(_ label: String, _ value: String) -> some View { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d43bdec398..37ed0f2716 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -472,6 +472,7 @@ C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */; }; C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */; }; C3897720526685D55A27C56C /* AttributionPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */; }; + C4081D22F2E243A72190B820 /* CustomerCenterSheetOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */; }; C570A889C4ADAA2C30E657CC /* EvaluateRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FD04064F8C3475007D5CBA /* EvaluateRules.swift */; }; C576C1F4D9DF866BEE44477C /* PurchasePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51636FFB03A6F879BFB140FC /* PurchasePresentation.swift */; }; C5A1C6E1DB61246348A88768 /* PaywallManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C57C1CCAF97244AE0DC953F /* PaywallManagerMock.swift */; }; @@ -764,6 +765,7 @@ 29E672B0703E6D85A3C65888 /* StoreTransactionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionType.swift; sourceTree = ""; }; 2A0325FB47A06456B909BCB5 /* TestModeRestoreDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeRestoreDrawer.swift; sourceTree = ""; }; 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicePreloadScript.swift; sourceTree = ""; }; + 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterSheetOwnershipTests.swift; sourceTree = ""; }; 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionTransaction.swift; sourceTree = ""; }; 2B40F35B536F527B029D7BDE /* SWProductSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWProductSubscriptionPeriod.swift; sourceTree = ""; }; 2B42460730F1CDB856A35CFA /* Attribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attribution.swift; sourceTree = ""; }; @@ -1394,6 +1396,7 @@ isa = PBXGroup; children = ( F4E26CA3FAD8F62F5D902594 /* AccentColorRoundTripTests.swift */, + 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */, 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */, 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */, @@ -3606,6 +3609,7 @@ 59C8960F002CD6B88A2E372E /* CustomerCenterEventsTests.swift in Sources */, 26081D80FCF7BCD475103467 /* CustomerCenterManagerTests.swift in Sources */, F478921BA3C4CD34C2459742 /* CustomerCenterPathResolverTests.swift in Sources */, + C4081D22F2E243A72190B820 /* CustomerCenterSheetOwnershipTests.swift in Sources */, BD1784A9E99914C0748F918A /* CustomerCenterStringsTests.swift in Sources */, BA0F56BF5C028624554EEC89 /* CustomerCenterViewControllerTests.swift in Sources */, DC3ECD6BD248CCA5322CE05E /* CustomerCenterViewModelTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift new file mode 100644 index 0000000000..0a14d132a5 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -0,0 +1,75 @@ +// +// CustomerCenterSheetOwnershipTests.swift +// +// +// Created by Jordan Morgan on 28/08/2026. +// + +import Testing +import Foundation +import SwiftUI +@testable import SuperwallKit + +/// When the host owns the navigation, the Customer Center's screens are separate hosting +/// controllers and every one of them applies the sheet modifiers. Only the screen the user is +/// actually looking at may present, or two controllers race for the same sheet. +@Suite("Customer Center sheet ownership") +@MainActor +struct CustomerCenterSheetOwnershipTests { + @available(iOS 15.0, *) + private func makeViewModel() -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) + ) + return CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + } + + /// Reproduces the bindings a surface at `depth` sees, which is what decides whether it presents. + @available(iOS 15.0, *) + private func presents(depth: Int, viewModel: CustomerCenterViewModel) -> Bool { + depth == viewModel.pushDepth + } + + @available(iOS 15.0, *) + @Test("only the topmost surface presents a sheet") + func onlyTopmostPresents() { + let viewModel = makeViewModel() + viewModel.sheet = .refund(transactionId: 1, productId: "monthly") + + // Nothing pushed: the root owns it. + #expect(presents(depth: 0, viewModel: viewModel)) + + // A drill-down is pushed — the root must stand down or both try to present the same sheet. + viewModel.pushDepth = 1 + #expect(!presents(depth: 0, viewModel: viewModel)) + #expect(presents(depth: 1, viewModel: viewModel)) + } + + /// The depth has to come back down, or the screen the user returns to can never present again. + @available(iOS 15.0, *) + @Test("popping the topmost surface hands presentation back") + func poppingRestoresOwnership() { + let viewModel = makeViewModel() + viewModel.pushDepth = 1 + viewModel.pushDepth = 0 + #expect(presents(depth: 0, viewModel: viewModel)) + } + + /// Two screens popped at once are both removed, and UIKit doesn't promise which reports first. + /// A shallower screen's restore must not be overwritten by a deeper one, or the depth is left + /// above the surface the user is on and that surface is mute for the rest of the presentation. + @available(iOS 15.0, *) + @Test("restoring out of order does not strand the depth", arguments: [[1, 2], [2, 1]]) + func restoringOutOfOrderDoesNotStrand(removalOrder: [Int]) { + let viewModel = makeViewModel() + viewModel.pushDepth = 2 + + // Each removed screen applies the navigator's rule: take the lowest depth reported. + for depth in removalOrder { + viewModel.pushDepth = min(viewModel.pushDepth, depth - 1) + } + + #expect(viewModel.pushDepth == 0, "the root must be able to present again") + #expect(presents(depth: 0, viewModel: viewModel)) + } +} From 4e1d59d8bfb93298da01738d2d98bb69848805c8 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 13:11:34 -0500 Subject: [PATCH 45/64] fix(customer-center): reveal the loaded screen instead of cross-fading it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review of the loading cover found three things. The animation was applied to the whole content subtree, so the content's insertion joined the same transaction and took SwiftUI's default opacity transition — it faded in while the cover faded out, a cross-dissolve rather than the reveal that was wanted. The content now carries `.transition(.identity)`, leaving the cover as the only thing that animates. The animation stays on the container that owns the condition, since a modifier on the departing view isn't what supplies the transaction its removal transition runs in. The cover called `ignoresSafeArea`, which extended an opaque fill into the region behind the host's translucent navigation bar and flattened it for the duration of the load — chrome the pushed style promises not to touch. The overlay already fills the content area without it. It also hardcoded the system grouped background. It now prefers a configured `Appearance.background`, falling back to that colour. Nothing else reads `theme.background` yet, so this changes nothing today, but it means the cover isn't a second place to remember when background theming is wired up. Co-Authored-By: Claude Opus 5 --- .../Views/CustomerCenterView.swift | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 4edd86dd37..1d63bb86ca 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -45,6 +45,7 @@ public struct CustomerCenterView: View { private let navigator: CustomerCenterNavigating? @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.customerCenterCallbacks) private var callbacksBox /// Creates a Customer Center view. @@ -147,15 +148,54 @@ public struct CustomerCenterView: View { private var coreContent: some View { ZStack { - switch viewModel.state { - case .loading: + // `.identity` so the content doesn't animate: it belongs to the same transaction as the + // cover's removal, and without this it would take SwiftUI's default opacity transition and + // fade in while the cover fades out. The content should simply be there, revealed. + loadedContent.transition(.identity) + RestoreOverlay(viewModel: viewModel) + } + // The spinner covers the screen rather than standing in for it, so the content isn't built + // and swapped underneath the user — it's simply revealed as the cover fades. The animation + // lives here, on the view that owns the condition, because that's what supplies the + // transaction the cover's removal transition runs in. + .overlay(loadingCover) + .animation(loadingCoverAnimation, value: viewModel.state) + } + + @ViewBuilder + private var loadedContent: some View { + switch viewModel.state { + case .loading: + // Nothing yet — whether this becomes the management or the no-purchases screen isn't known + // until the load finishes, and guessing would show the wrong one for a frame. + Color.clear + case .management: + ManagementScreenView(viewModel: viewModel) + case .noPurchases: + NoPurchasesScreenView(viewModel: viewModel) + } + } + + /// The cover's fade. Applied to the container rather than the cover itself: a modifier on the + /// departing view isn't what drives its removal transition. + private var loadingCoverAnimation: Animation? { + reduceMotion ? nil : .easeOut(duration: 0.24) + } + + @ViewBuilder + private var loadingCover: some View { + if viewModel.state == .loading { + ZStack { + // Opaque, so nothing shows through and no touch reaches a half-built screen. Honours a + // configured background, falling back to the grouped-list colour the screen uses. + // + // Deliberately not `ignoresSafeArea`: the overlay already fills the content area, and + // extending it would paint an opaque fill into the region behind the host's translucent + // navigation bar — flattening chrome that the pushed style promises not to touch. + (theme.background ?? Color(uiColor: .systemGroupedBackground)) ProgressView().accessibilityIdentifier("customer_center.loading") - case .management: - ManagementScreenView(viewModel: viewModel) - case .noPurchases: - NoPurchasesScreenView(viewModel: viewModel) } - RestoreOverlay(viewModel: viewModel) + .transition(.opacity) } } From 9e4ca76836a24f439906714d6757b496ef9428d2 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 13:32:39 -0500 Subject: [PATCH 46/64] fix(customer-center): make pushed drill-downs own their sheets, and restore an unrelated example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things from review, four of them live bugs in the pushed presentation. The sheet gate still didn't work. `.customerCenterSheets(...)` was applied outside the `.environment(...)` writes, and a modifier resolves its own `@Environment` against the values above it — so every pushed surface read the default depth of 0 and, once anything was pushed, no surface owned the sheet at all: refund, manage, survey and change-plan all did nothing from a purchase detail. The depth is now passed as an argument rather than through the environment, which removes the ordering hazard rather than getting the order right this time. Gating the sheet *setters* on that depth was my mistake and made things worse: the depth drops when a screen is popped, with no regard for whether that screen has a sheet open, so a pop while one was up vetoed its dismissal, left `viewModel.sheet` non-nil, skipped `sheetDidDismiss()`, and let the root re-present the stale sheet unprompted. Only the getters are gated now, which is all that's needed to stop two surfaces racing. Pushed drill-downs had no dismissal veto. Covering one — a host push on top, a presentation, a tab switch — let the visibility debounce fire and latch `didDismiss`, silencing the genuine teardown. Same fix the root controller already had. Depth was restored from `didMove(toParent:)` alone, which a pop doesn't reliably call: the new tests caught that the depth never came back down, so after any drill-down the root could never present again. Removal is reported from `viewDidDisappear` when the controller is actually going, with `didMove` still covering the covered-then-popped case, latched between them. The tests are rewritten to drive `CustomerCenterPushNavigator` and the controllers it pushes. The previous versions restated the arithmetic locally and passed against a gate that presented nothing — the same hollow-test mistake this suite exists to avoid. They now wait for a pushed controller to actually appear, since UIKit reports no removal for one that never did. Also restores `Examples/Basic/Basic/HomeView.swift`. Its navigation bar styling was deleted in a working-tree change that predates this branch and got swept into 1b1d7b3 by a blanket `git add`. It has nothing to do with the Customer Center. Co-Authored-By: Claude Opus 5 --- Examples/Basic/Basic/HomeView.swift | 5 +- .../UIKit/CustomerCenterPushNavigator.swift | 37 +++- .../Views/CustomerCenterNavigator.swift | 16 -- .../Views/CustomerCenterSheets.swift | 40 +++-- .../CustomerCenterSheetOwnershipTests.swift | 170 ++++++++++++++---- 5 files changed, 201 insertions(+), 67 deletions(-) diff --git a/Examples/Basic/Basic/HomeView.swift b/Examples/Basic/Basic/HomeView.swift index 0e3035f8ae..f8bc3ada8c 100644 --- a/Examples/Basic/Basic/HomeView.swift +++ b/Examples/Basic/Basic/HomeView.swift @@ -19,7 +19,10 @@ struct HomeView: View { init(isLoggedIn: Binding) { _isLoggedIn = isLoggedIn - + UINavigationBar.appearance().titleTextAttributes = [ + .foregroundColor: UIColor.white, + .font: UIFont.rubikBold(.five) + ] } var firstName: String? { diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift index 3fa3883920..759ba2918c 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -35,12 +35,10 @@ final class CustomerCenterPushNavigator: CustomerCenterNavigating { // whichever appearance was active when it opened, while the root kept tracking the change. let hosted = CustomerCenterThemedContainer( appearance: viewModel.configuration.appearance, - content: destination + content: destination.customerCenterSheets(viewModel: viewModel, surfaceDepth: depth) ) .environment(\.customerCenterStrings, viewModel.strings) .environment(\.customerCenterNavigator, self) - .environment(\.customerCenterSurfaceDepth, depth) - .customerCenterSheets(viewModel: viewModel) let controller = CustomerCenterPushedHostingController(rootView: hosted) controller.onRemovedFromParent = { [weak self] in @@ -53,6 +51,9 @@ final class CustomerCenterPushNavigator: CustomerCenterNavigating { guard let self else { return } self.viewModel.pushDepth = min(self.viewModel.pushDepth, depth - 1) } + controller.onCoveredWhileStillInStack = { [weak self] in + self?.viewModel.cancelPendingDismissal() + } viewModel.pushDepth = depth navigationController.pushViewController(controller, animated: true) } @@ -78,15 +79,41 @@ private struct CustomerCenterThemedContainer: View { } } -/// A hosting controller that reports being popped, so the navigator can restore the depth. +/// A hosting controller that reports being popped, so the navigator can restore the depth, and +/// vetoes the dismissal debounce when it is merely covered. @available(iOS 15.0, *) private final class CustomerCenterPushedHostingController: UIHostingController { var onRemovedFromParent: (() -> Void)? + var onCoveredWhileStillInStack: (() -> Void)? + private var hasReportedRemoval = false + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent || isBeingDismissed { + reportRemoval() + return + } + // Merely covered — the host pushed its own screen on top, presented something, or the user + // switched tabs. `super` has just forwarded the disappearance into SwiftUI, whose + // `onDisappear` dropped the visible-surface count to zero and armed the dismissal debounce. + // Left to fire it would deliver `customerCenterDidDismiss()` and latch, silencing the real + // teardown later. Same veto the root controller performs, one level down. + onCoveredWhileStillInStack?() + } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) + // Not redundant with the above: a controller that was already covered has disappeared once + // already, so being popped from under the covering screen produces no second disappearance. if parent == nil { - onRemovedFromParent?() + reportRemoval() } } + + /// Latched, because an ordinary pop is both a disappearance and a removal from the container. + private func reportRemoval() { + guard !hasReportedRemoval else { return } + hasReportedRemoval = true + onRemovedFromParent?() + } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift index b3d45504c7..b51d69cd6d 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift @@ -33,22 +33,6 @@ extension EnvironmentValues { } } -@available(iOS 15.0, *) -private struct CustomerCenterSurfaceDepthKey: EnvironmentKey { - static let defaultValue = 0 -} - -@available(iOS 15.0, *) -extension EnvironmentValues { - /// How many Customer Center screens have been pushed above the root through UIKit. Compared - /// against ``CustomerCenterViewModel/pushDepth`` so that only the topmost surface presents - /// sheets — otherwise every screen still in the stack would race to present the same one. - var customerCenterSurfaceDepth: Int { - get { self[CustomerCenterSurfaceDepthKey.self] } - set { self[CustomerCenterSurfaceDepthKey.self] = newValue } - } -} - /// A row that drills into another Customer Center screen, by whichever mechanism the surrounding /// navigation supports. @available(iOS 15.0, *) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index 58e152b1ee..cccde49d1f 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -11,22 +11,45 @@ import SwiftUI @available(iOS 15.0, *) extension View { - func customerCenterSheets(viewModel: CustomerCenterViewModel) -> some View { - modifier(CustomerCenterSheetsModifier(viewModel: viewModel)) + /// - Parameter surfaceDepth: How deep this screen sits in the Customer Center's own pushed + /// stack; `0` for the root. Passed rather than read from the environment because a modifier + /// resolves its `@Environment` against the values *above* it, so a caller that applied this + /// outside its `.environment(...)` writes would silently read the default and the gate would + /// stop working. + func customerCenterSheets(viewModel: CustomerCenterViewModel, surfaceDepth: Int = 0) -> some View { + modifier(CustomerCenterSheetsModifier(viewModel: viewModel, surfaceDepth: surfaceDepth)) + } +} + +/// Which surface owns sheet presentation. A free function so the rule the modifier applies can be +/// exercised directly rather than restated by a test. +enum CustomerCenterSheetOwnership { + static func isTopmost(surfaceDepth: Int, pushDepth: Int) -> Bool { + surfaceDepth == pushDepth } } @available(iOS 15.0, *) private struct CustomerCenterSheetsModifier: ViewModifier { @ObservedObject var viewModel: CustomerCenterViewModel + let surfaceDepth: Int @Environment(\.customerCenterStrings) private var strings - @Environment(\.customerCenterSurfaceDepth) private var depth /// Every screen still in the stack applies this modifier, so without a check they'd all try to /// present the same sheet. Gating the bindings rather than the modifier keeps the view tree /// stable — swapping modifiers mid-update is what stopped the manage sheet appearing once /// before. - private var isTopmost: Bool { depth == viewModel.pushDepth } + /// + /// Only the getters are gated. Gating the setters too would let a screen lose the right to + /// clear a sheet it already has open: the depth drops when the screen is popped, without regard + /// for whether a sheet is up, so the dismissal would be vetoed, `sheetDidDismiss()` would never + /// run, and the root would re-present the stale sheet the moment it became topmost again. + private var isTopmost: Bool { + CustomerCenterSheetOwnership.isTopmost( + surfaceDepth: surfaceDepth, + pushDepth: viewModel.pushDepth + ) + } private var isManagePresented: Binding { .init( @@ -34,7 +57,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { guard isTopmost, case .manageSubscriptions = viewModel.sheet else { return false } return true }, - set: { if isTopmost, !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } } + set: { if !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } } ) } private var refundBinding: Binding { @@ -43,7 +66,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { guard isTopmost, case .refund = viewModel.sheet else { return false } return true }, - set: { if isTopmost, !$0, case .refund = viewModel.sheet { viewModel.sheet = nil } } + set: { if !$0, case .refund = viewModel.sheet { viewModel.sheet = nil } } ) } private var itemSheet: Binding { @@ -55,9 +78,7 @@ private struct CustomerCenterSheetsModifier: ViewModifier { default: return nil } }, - // Guarded like the getter: a surface that isn't topmost must not clear a sheet another - // screen owns, which would dismiss it and run its deferred follow-up on the wrong screen. - set: { if isTopmost { viewModel.sheet = $0 } } + set: { viewModel.sheet = $0 } ) } private var manageGroupId: String? { @@ -74,7 +95,6 @@ private struct CustomerCenterSheetsModifier: ViewModifier { } private var onItemSheetDismiss: () -> Void { { - guard isTopmost else { return } if viewModel.pendingSurvey != nil { viewModel.cancelSurvey() } Task { await viewModel.sheetDidDismiss() } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift index 0a14d132a5..fdb8fbf7ce 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -8,68 +8,168 @@ import Testing import Foundation import SwiftUI +import UIKit @testable import SuperwallKit /// When the host owns the navigation, the Customer Center's screens are separate hosting /// controllers and every one of them applies the sheet modifiers. Only the screen the user is /// actually looking at may present, or two controllers race for the same sheet. -@Suite("Customer Center sheet ownership") +/// +/// These drive `CustomerCenterPushNavigator` and the controllers it pushes rather than restating +/// their arithmetic — an earlier version of this file re-implemented the rules locally and passed +/// while the real gate presented nothing at all. +@Suite("Customer Center sheet ownership", .serialized) @MainActor struct CustomerCenterSheetOwnershipTests { + private final class ProbeDelegate: CustomerCenterDelegate { + var didDismissCount = 0 + func customerCenterDidDismiss() { didDismissCount += 1 } + } + @available(iOS 15.0, *) - private func makeViewModel() -> CustomerCenterViewModel { + private func makeViewModel( + delegate: CustomerCenterDelegate? = nil, + dismissDebounceInterval: TimeInterval = 0.6 + ) -> CustomerCenterViewModel { let (deps, _, _) = CustomerCenterDependencies.mock( info: CustomerInfo(subscriptions: [], nonSubscriptions: [], entitlements: []) ) - return CustomerCenterViewModel(configuration: .default, dependencies: deps, strings: .english) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english, + dismissDebounceInterval: dismissDebounceInterval + ) + if let delegate { + viewModel.callbacks = CustomerCenterDelegateAdapter( + swiftDelegate: delegate, + objcDelegate: nil + ).makeCallbacks() + } + return viewModel } - /// Reproduces the bindings a surface at `depth` sees, which is what decides whether it presents. - @available(iOS 15.0, *) - private func presents(depth: Int, viewModel: CustomerCenterViewModel) -> Bool { - depth == viewModel.pushDepth + private func makeWindow(rootViewController: UIViewController) -> UIWindow { + let window: UIWindow + if let scene = UIApplication.sharedApplication?.connectedScenes.first as? UIWindowScene { + window = UIWindow(windowScene: scene) + window.frame = scene.screen.bounds + } else { + window = UIWindow(frame: UIScreen.main.bounds) + } + window.rootViewController = rootViewController + return window } + private func spinRunLoop(timeout: TimeInterval, until condition: () -> Bool) { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + } + } + + /// The rule the sheet modifier applies, exercised directly. + @Test("the root owns sheets until something is pushed over it") + func ownershipFollowsDepth() { + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: 0)) + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: 1)) + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 1, pushDepth: 1)) + // A surface deeper than the current depth is stale and must not present either. + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 2, pushDepth: 1)) + } + + // MARK: - Driving the real navigator + @available(iOS 15.0, *) - @Test("only the topmost surface presents a sheet") - func onlyTopmostPresents() { + @Test("pushing a drill-down takes ownership, popping it hands ownership back") + func pushAndPopMoveOwnership() { let viewModel = makeViewModel() - viewModel.sheet = .refund(transactionId: 1, productId: "monthly") + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host - // Nothing pushed: the root owns it. - #expect(presents(depth: 0, viewModel: viewModel)) + navigator.push(Text("purchase history")) + // Wait for the pushed screen to actually be on screen: UIKit only reports a removal for a + // controller that appeared, so asserting on `viewControllers.count` alone would test a + // controller that never lived. + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + #expect(viewModel.pushDepth == 1) + #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: viewModel.pushDepth)) - // A drill-down is pushed — the root must stand down or both try to present the same sheet. - viewModel.pushDepth = 1 - #expect(!presents(depth: 0, viewModel: viewModel)) - #expect(presents(depth: 1, viewModel: viewModel)) + navigation.popViewController(animated: false) + spinRunLoop(timeout: 1) { viewModel.pushDepth == 0 } + #expect(viewModel.pushDepth == 0, "the root must be able to present again") + + window.isHidden = true } - /// The depth has to come back down, or the screen the user returns to can never present again. + /// Two screens popped at once are both removed and UIKit doesn't promise which reports first. + /// Driven through the real controllers rather than by restating the navigator's arithmetic. @available(iOS 15.0, *) - @Test("popping the topmost surface hands presentation back") - func poppingRestoresOwnership() { + @Test("popping two drill-downs at once does not strand ownership") + func poppingTwoAtOnceDoesNotStrand() { let viewModel = makeViewModel() - viewModel.pushDepth = 1 - viewModel.pushDepth = 0 - #expect(presents(depth: 0, viewModel: viewModel)) + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + // The second push comes from the screen that was just pushed. + navigator.presenter = navigation.viewControllers.last + navigator.push(Text("purchase detail")) + spinRunLoop(timeout: 2) { navigation.viewControllers.count == 3 } + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + #expect(viewModel.pushDepth == 2) + + navigation.popToRootViewController(animated: false) + spinRunLoop(timeout: 1) { viewModel.pushDepth == 0 } + + #expect(viewModel.pushDepth == 0) + #expect(CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 0, pushDepth: viewModel.pushDepth)) + + window.isHidden = true } - /// Two screens popped at once are both removed, and UIKit doesn't promise which reports first. - /// A shallower screen's restore must not be overwritten by a deeper one, or the depth is left - /// above the surface the user is on and that surface is mute for the rest of the presentation. + /// A pushed screen being covered is not a teardown, and the debounce must be vetoed there just + /// as it is on the root controller — otherwise `didDismiss` latches and the real teardown is + /// silent. @available(iOS 15.0, *) - @Test("restoring out of order does not strand the depth", arguments: [[1, 2], [2, 1]]) - func restoringOutOfOrderDoesNotStrand(removalOrder: [Int]) { - let viewModel = makeViewModel() - viewModel.pushDepth = 2 + @Test("covering a drill-down does not deliver a dismissal") + func coveringADrillDownDoesNotDismiss() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let viewModel = makeViewModel(delegate: delegate, dismissDebounceInterval: debounce) + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } - // Each removed screen applies the navigator's rule: take the lowest depth reported. - for depth in removalOrder { - viewModel.pushDepth = min(viewModel.pushDepth, depth - 1) - } + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } - #expect(viewModel.pushDepth == 0, "the root must be able to present again") - #expect(presents(depth: 0, viewModel: viewModel)) + // The drill-down reports the disappearance SwiftUI uses to arm the debounce… + viewModel.surfaceDidDisappear() + // …and the host covers it with its own screen rather than popping it. + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { navigation.viewControllers.count == 3 } + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 0, "being covered is not being torn down") + + window.isHidden = true } } From 17f81ff3790f622fe1752e79007591aced4c880a Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 13:41:53 -0500 Subject: [PATCH 47/64] fix(customer-center): let the loading cover run edge to edge again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confining it to the safe area left the spinner floating in a frame of unpainted screen — white above and below the grey fill, most visible in the modal presentation. The change that caused it was made on a review note that extending an opaque fill under the host's translucent navigation bar would flatten it. That doesn't hold: the screen this becomes is an inset-grouped `List`, which already runs under the bar and past the home indicator with the same `systemGroupedBackground`. The region either side of the load is the same colour, so there is nothing to flatten — only continuity to preserve. Co-Authored-By: Claude Opus 5 --- Examples/Advanced/Advanced/HomeView.swift | 14 +++++++++++++- .../Advanced/Advanced/SuperwallAdvancedApp.swift | 5 ++++- .../CustomerCenter/Views/CustomerCenterView.swift | 9 +++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 76035d0230..8ed78dcf02 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -33,13 +33,25 @@ struct HomeView: View { /// Presents the Customer Center with a code-built configuration and a delegate that prints /// each callback it receives. See `CustomerCenterExampleDelegate`. private func presentCustomerCenter() { + // Attached to the cancellation path below, so answering it is what unlocks Apple's cancel + // sheet. `nil` titles fall back to the built-in localized strings, which every locale the SDK + // ships already has for these three option ids. + let cancelSurvey = CustomerCenterConfiguration.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) + ] + ) 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: "manage_subscription", type: .manageSubscription, survey: cancelSurvey), .init(id: "faq", type: .url(URL(string: "https://superwall.com/faq")!, openMethod: .inApp)), .init(id: "contact_support", type: .contactSupport) ] diff --git a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift index 4473337e96..1260fb9506 100644 --- a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift +++ b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift @@ -21,7 +21,10 @@ struct SuperwallAdvancedApp: App { let apiKey = "pk_e361c8a9662281f4249f2fa11d1a63854615fa80e15e7a4d" // MARK: - Option 1: Let Superwall handle everything - Superwall.configure(apiKey: apiKey) + let options = SuperwallOptions() + options.testModeBehavior = .never + Superwall.configure(apiKey: apiKey, options: options) + // MARK: - Option 2: Use a Purchase Controller with StoreKit /* diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 1d63bb86ca..899bb77653 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -188,13 +188,14 @@ public struct CustomerCenterView: View { ZStack { // Opaque, so nothing shows through and no touch reaches a half-built screen. Honours a // configured background, falling back to the grouped-list colour the screen uses. - // - // Deliberately not `ignoresSafeArea`: the overlay already fills the content area, and - // extending it would paint an opaque fill into the region behind the host's translucent - // navigation bar — flattening chrome that the pushed style promises not to touch. (theme.background ?? Color(uiColor: .systemGroupedBackground)) ProgressView().accessibilityIdentifier("customer_center.loading") } + // Edge to edge, because the `List` this becomes already runs under the navigation bar and + // past the home indicator with the same background. Confining the cover to the safe area + // leaves it floating in a frame of unpainted screen, and the region it covers ends up the + // same colour either way — so there's no bar being flattened, only continuity. + .ignoresSafeArea() .transition(.opacity) } } From 058dacf60cf43e103b4bbc2b62946e9036837a43 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 13:52:10 -0500 Subject: [PATCH 48/64] fix(customer-center): don't half-wire background theming on the loading cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cover was the only reader of `theme.background` in the SDK — `Appearance.background` is computed and consumed nowhere else. Honouring it there alone meant a host that configured a background got a tinted cover that then faded to reveal an untinted inset-grouped list: a colour flip on exactly the screens that had asked for a background, and one that didn't exist before the cover did. Back to the grouped-list colour. `Appearance.background` belongs here when it's wired through the screens themselves, not before. Co-Authored-By: Claude Opus 5 --- Examples/Advanced/Advanced/HomeView.swift | 14 +------------- .../Advanced/Advanced/SuperwallAdvancedApp.swift | 5 +---- .../CustomerCenter/Views/CustomerCenterView.swift | 11 ++++++++--- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 8ed78dcf02..76035d0230 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -33,25 +33,13 @@ struct HomeView: View { /// Presents the Customer Center with a code-built configuration and a delegate that prints /// each callback it receives. See `CustomerCenterExampleDelegate`. private func presentCustomerCenter() { - // Attached to the cancellation path below, so answering it is what unlocks Apple's cancel - // sheet. `nil` titles fall back to the built-in localized strings, which every locale the SDK - // ships already has for these three option ids. - let cancelSurvey = CustomerCenterConfiguration.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) - ] - ) 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, survey: cancelSurvey), + .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) ] diff --git a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift index 1260fb9506..4473337e96 100644 --- a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift +++ b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift @@ -21,10 +21,7 @@ struct SuperwallAdvancedApp: App { let apiKey = "pk_e361c8a9662281f4249f2fa11d1a63854615fa80e15e7a4d" // MARK: - Option 1: Let Superwall handle everything - let options = SuperwallOptions() - options.testModeBehavior = .never - Superwall.configure(apiKey: apiKey, options: options) - + Superwall.configure(apiKey: apiKey) // MARK: - Option 2: Use a Purchase Controller with StoreKit /* diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 899bb77653..682cf5b6f1 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -186,9 +186,14 @@ public struct CustomerCenterView: View { private var loadingCover: some View { if viewModel.state == .loading { ZStack { - // Opaque, so nothing shows through and no touch reaches a half-built screen. Honours a - // configured background, falling back to the grouped-list colour the screen uses. - (theme.background ?? Color(uiColor: .systemGroupedBackground)) + // Opaque, so nothing shows through and no touch reaches a half-built screen. Matches the + // `insetGrouped` list it becomes. + // + // Deliberately not `theme.background`: nothing else in the SDK reads it, so honouring it + // here alone would tint the cover and then fade to an untinted list — a colour flip on + // exactly the screens that configured a background. It belongs here when background + // theming is wired up across the screens, not before. + Color(uiColor: .systemGroupedBackground) ProgressView().accessibilityIdentifier("customer_center.loading") } // Edge to edge, because the `List` this becomes already runs under the navigation bar and From 576f842db1cc1a714e218b821b7169384115a0b3 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 15:35:06 -0500 Subject: [PATCH 49/64] fix(customer-center): restore the Advanced example, and prove the cover test's veto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puts back two things a blanket `git add` swept into 058dacf from the working tree: the Advanced example's cancellation survey on the manage path, and its `testModeBehavior = .never`. Both are part of this PR's demo of the feature; neither had anything to do with that commit's subject. Also answers two review threads on the controller tests. `pushedCoverDoesNotFireLateDismissal` called `viewDidDisappear` after the run loop had settled, so the veto always ran last — the one ordering production doesn't have to guarantee — and nothing established that SwiftUI's `onDisappear` had armed anything in this harness. The debounce is now armed explicitly before the veto, so the test fails if the veto stops cancelling. Production ordering is sound for a different reason, noted there: `viewDidDisappear` calls `super` first, which is what forwards the disappearance into SwiftUI. And the bar test's doc comment still opened with the behaviour that change removed, contradicting the two sentences under it. Co-Authored-By: Claude Opus 5 --- Examples/Advanced/Advanced/HomeView.swift | 14 +++++++++++++- .../Advanced/Advanced/SuperwallAdvancedApp.swift | 5 ++++- .../UIKit/CustomerCenterViewControllerTests.swift | 11 +++++++---- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 76035d0230..8ed78dcf02 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -33,13 +33,25 @@ struct HomeView: View { /// Presents the Customer Center with a code-built configuration and a delegate that prints /// each callback it receives. See `CustomerCenterExampleDelegate`. private func presentCustomerCenter() { + // Attached to the cancellation path below, so answering it is what unlocks Apple's cancel + // sheet. `nil` titles fall back to the built-in localized strings, which every locale the SDK + // ships already has for these three option ids. + let cancelSurvey = CustomerCenterConfiguration.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) + ] + ) 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: "manage_subscription", type: .manageSubscription, survey: cancelSurvey), .init(id: "faq", type: .url(URL(string: "https://superwall.com/faq")!, openMethod: .inApp)), .init(id: "contact_support", type: .contactSupport) ] diff --git a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift index 4473337e96..1260fb9506 100644 --- a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift +++ b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift @@ -21,7 +21,10 @@ struct SuperwallAdvancedApp: App { let apiKey = "pk_e361c8a9662281f4249f2fa11d1a63854615fa80e15e7a4d" // MARK: - Option 1: Let Superwall handle everything - Superwall.configure(apiKey: apiKey) + let options = SuperwallOptions() + options.testModeBehavior = .never + Superwall.configure(apiKey: apiKey, options: options) + // MARK: - Option 2: Use a Purchase Controller with StoreKit /* diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift index 8a2bafe8db..e76560b384 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -113,6 +113,13 @@ struct CustomerCenterViewControllerTests { // Covered by the host's own screen. navigation.pushViewController(UIViewController(), animated: false) spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + + // Arm the debounce explicitly rather than trusting SwiftUI's `onDisappear` to have fired in + // this harness. The claim under test is that the veto cancels an *armed* dismissal, and with + // nothing armed the test would pass whether or not the veto works at all. In production the + // ordering is guaranteed the other way round: `viewDidDisappear` calls `super` first, which + // is what forwards the disappearance into SwiftUI and arms this. + controller.viewModel.surfaceDidDisappear() controller.viewDidDisappear(false) try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) @@ -190,10 +197,6 @@ struct CustomerCenterViewControllerTests { // MARK: - Chrome - - - /// 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. /// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on /// the way out. It no longer touches the bar in any style — the host's chrome is theirs. @available(iOS 15.0, *) From 00c2354136033e1fba731e90e8895b27982e5b9b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 28 Aug 2026 15:41:40 -0500 Subject: [PATCH 50/64] refactor(customer-center): expose only the appearance colour that does something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Appearance` published five colour slots and only `accent` ever reached a pixel. The other four were parsed from the host's hex strings, round-tripped through `Codable`, compared and hashed, and applied nowhere — so a host could set a background or text colour, get no error, and see no change. Shipping them in 4.17.0 would have meant honouring them later or removing them later, and removing a property from a public, `Codable`, Objective-C-exposed type is a breaking change. They'll come back when they're wired up. Also takes two test-only edits back out of the Advanced example: `testModeBehavior = .never`, and the cancellation survey on the manage path. Neither was meant to ship — they were left over from exercising the feature by hand, and I put them back in the previous commit while undoing an unrelated blanket-add. The example's only change from this PR is now the Customer Center button and its delegate; `SuperwallAdvancedApp.swift` is untouched. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- Examples/Advanced/Advanced/HomeView.swift | 14 +-------- .../Advanced/SuperwallAdvancedApp.swift | 5 +--- ...stomerCenterConfiguration+Appearance.swift | 30 ++++++------------- .../Views/CustomerCenterEnvironment.swift | 8 ----- .../Documentation.docc/CustomerCenter.md | 2 +- 6 files changed, 13 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301438e682..bc967d7033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### 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 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`), including an accent colour for light and dark. Requires iOS 15+. - Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. - The Customer Center's update banner now finds the published version itself, by looking the app up on the App Store, so `latestAppVersion` no longer has to be kept current by hand. Set `SuperwallOptions.customerCenter.support.checksAppStoreForUpdates = false` to opt out, or keep setting `latestAppVersion` — a configured version always wins and skips the lookup. The check is skipped on TestFlight, sandbox and simulator builds, whose version is normally ahead of the App Store. Note that Apple phases releases in over seven days while the lookup sees a new version immediately, so early in a release some customers may be prompted to update before the build reaches them. - The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. 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. diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 8ed78dcf02..76035d0230 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -33,25 +33,13 @@ struct HomeView: View { /// Presents the Customer Center with a code-built configuration and a delegate that prints /// each callback it receives. See `CustomerCenterExampleDelegate`. private func presentCustomerCenter() { - // Attached to the cancellation path below, so answering it is what unlocks Apple's cancel - // sheet. `nil` titles fall back to the built-in localized strings, which every locale the SDK - // ships already has for these three option ids. - let cancelSurvey = CustomerCenterConfiguration.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) - ] - ) 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, survey: cancelSurvey), + .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) ] diff --git a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift index 1260fb9506..4473337e96 100644 --- a/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift +++ b/Examples/Advanced/Advanced/SuperwallAdvancedApp.swift @@ -21,10 +21,7 @@ struct SuperwallAdvancedApp: App { let apiKey = "pk_e361c8a9662281f4249f2fa11d1a63854615fa80e15e7a4d" // MARK: - Option 1: Let Superwall handle everything - let options = SuperwallOptions() - options.testModeBehavior = .never - Superwall.configure(apiKey: apiKey, options: options) - + Superwall.configure(apiKey: apiKey) // MARK: - Option 2: Use a Purchase Controller with StoreKit /* diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift index 6fc90ee64b..56113ea570 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift @@ -11,42 +11,30 @@ import UIKit extension CustomerCenterConfiguration { // MARK: - Appearance + /// Colour overrides for the Customer Center. + /// + /// Only the accent is applied. Slots for background, text and button colours existed here + /// before anything read them; exposing colours that reach no pixel would have meant either + /// honouring them later or removing them later, and removing them from a public, `Codable`, + /// Objective-C-exposed type is a breaking change. They come back when they're wired up. @objc(SWKCustomerCenterAppearance) @objcMembers public final class Appearance: NSObject, Codable { + /// Tints controls and links. `nil` uses the system accent. public var accent: ColorPair? - public 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 - ) { + public init(accent: 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 + return accent == other.accent } 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() } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift index b004f34986..c6d96a214c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift @@ -10,10 +10,6 @@ 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? { @@ -21,10 +17,6 @@ struct CustomerCenterTheme { 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) } } diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index dd59b86a59..9a903718df 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -7,7 +7,7 @@ A native, self-service screen where users can view and manage their subscription 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. +tailor which paths appear, their titles, surveys and accent colour to match your app. The Customer Center requires **iOS 15.0+**. From 290e6268570abbec78e59cd191657c2f294c3080 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 11:21:40 -0500 Subject: [PATCH 51/64] test(customer-center): cover the mixed-store and multi-subscription states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design review needs to see what a customer with more than one subscription gets, and what changes when one of them didn't come from Apple. Those are exactly the states that are painful to produce for real — a live Stripe subscription alongside a live App Store one, two App Store subscriptions in different groups — so the harness fabricates them instead. Adds eight states (23–30): web only, web with no management URL configured, App Store and web together, that mixed customer drilled into each of the two subscriptions, the two-group customer drilled in, a comped entitlement, and a family-shared subscription. Family sharing isn't in `CustomerInfo` — it's a StoreKit lookup — so `makeViewModel` now takes a `familyShared` set and feeds it through the transaction lookup mock. The web fixture gets a priced catalogue entry so its card renders the way production does: price yes, display name no. Co-Authored-By: Claude Opus 5 --- .../Views/DesignReviewSnapshots.swift | 117 +++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift index 05400798ab..484e85ddd8 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -134,6 +134,17 @@ struct DesignReviewSnapshots { subscriptionGroupId: nil, isAutoRenewable: false ), + // A web product as it actually arrives: the catalogue supplies its price, but `/v1/products` + // carries no display name, so the card is headed with the raw identifier. + "web_pro_monthly": .init( + productId: "web_pro_monthly", + title: "web_pro_monthly", + localizedPrice: "$12.99", + price: 12.99, + localizedPeriod: "month", + subscriptionGroupId: nil, + isAutoRenewable: true + ), "extra_theme": .init( productId: "extra_theme", title: "Midnight Theme", @@ -175,8 +186,13 @@ struct DesignReviewSnapshots { nonSubscriptions: [NonSubscriptionTransaction] = [], entitlements: [Entitlement] = [], configuration: CustomerCenterConfiguration? = nil, - environment: EnvironmentMock = EnvironmentMock() + environment: EnvironmentMock = EnvironmentMock(), + familyShared: Set = [] ) async -> CustomerCenterViewModel { + // Family sharing is the one gating input that isn't in `CustomerInfo` — it comes from a + // StoreKit transaction lookup — so it has to be faked separately. + let lookup = StoreKitTransactionLookupMock() + lookup.familyShared = familyShared let (dependencies, _, _) = CustomerCenterDependencies.mock( info: CustomerInfo( subscriptions: subscriptions, @@ -184,7 +200,8 @@ struct DesignReviewSnapshots { entitlements: entitlements ), products: catalogue, - environment: environment + environment: environment, + lookup: lookup ) let viewModel = CustomerCenterViewModel( configuration: configuration ?? defaultConfiguration(), @@ -520,6 +537,102 @@ struct DesignReviewSnapshots { restoreEmpty.restoreState = .notFound capture("22-restore-nothing-found", directory: directory, viewModel: restoreEmpty) + // MARK: - Mixed stores and multiple groups + + let webSubscription = subscription( + productId: "web_pro_monthly", + transactionId: "w1", + purchaseDate: -20, + expiresIn: 10, + groupId: nil, + store: .stripe + ) + + // 23. A web subscription on its own. No Change plan or Refund — both are App Store only — + // and the manage row points at the web management page instead. + capture( + "23-web-subscription-only", + directory: directory, + viewModel: await makeViewModel(subscriptions: [webSubscription]) + ) + + // 24. The same customer with no management URL configured, which is what most apps will + // have on day one: the row stays and explains where the link is. + let noWebURL = defaultConfiguration() + noWebURL.support.webManagementURL = nil + capture( + "24-web-subscription-no-management-url", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [webSubscription], + configuration: noWebURL, + environment: EnvironmentMock(webManagementURL: nil) + ) + ) + + // 25. App Store and web active at once — the case where the two stores sit side by side and + // the duplicate warning fires. + let mixedStores = defaultConfiguration() + mixedStores.warnsAboutDuplicateSubscriptions = true + let mixed = await makeViewModel( + subscriptions: [subscription(), webSubscription], + configuration: mixedStores + ) + capture("25-app-store-and-web", directory: directory, viewModel: mixed) + + // 26 & 27. The point of the mixed case: the same screen offers different actions per + // purchase, because what a store permits differs. Apple's sub can change plan and + // request a refund; the web one can only be managed on the web. + if let appStorePurchase = mixed.purchases.first(where: { $0.store == .appStore }) { + captureDetail("26-mixed-detail-app-store", directory: directory, viewModel: mixed) { + PurchaseDetailScreenView(viewModel: mixed, purchase: appStorePurchase) + } + } + if let webPurchase = mixed.purchases.first(where: { $0.store == .stripe }) { + captureDetail("27-mixed-detail-web", directory: directory, viewModel: mixed) { + PurchaseDetailScreenView(viewModel: mixed, purchase: webPurchase) + } + } + + // 28. Two App Store subscriptions in different subscription groups, drilled into one of + // them. Change plan is scoped to that subscription's own group — Apple's sheet takes a + // single group, so there is no combined plan picker to offer. + let twoGroups = await makeViewModel( + subscriptions: [ + subscription(), + subscription( + productId: "coach_monthly", + transactionId: "t2", + purchaseDate: -10, + groupId: "group_coach" + ) + ] + ) + if let first = twoGroups.purchases.first { + captureDetail("28-two-groups-detail", directory: directory, viewModel: twoGroups) { + PurchaseDetailScreenView(viewModel: twoGroups, purchase: first) + } + } + + // 29. An entitlement with no transaction behind it — comped, or granted by hand. There is + // nothing to manage, so the management row is absent rather than pointing nowhere. + capture( + "29-comped-entitlement", + directory: directory, + viewModel: await makeViewModel(entitlements: [Entitlement(id: "pro")]) + ) + + // 30. Shared through Family Sharing. Cancel, refund and change plan all disappear: the + // purchase belongs to the organiser, not this customer. + capture( + "30-family-shared", + directory: directory, + viewModel: await makeViewModel( + subscriptions: [subscription()], + familyShared: ["monthly_pro"] + ) + ) + let written = (try? FileManager.default.contentsOfDirectory(atPath: directory.path))? .filter { $0.hasSuffix(".png") } .count ?? 0 From f01911b863190428f168eb6ae61cad1054059154 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 11:57:58 -0500 Subject: [PATCH 52/64] fix(customer-center): make the design-review fixtures show the states they claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the snapshot scenarios rendered something other than their caption, which is worse than not having them: a designer reviews the PNG, not the fixture. - 23 and 24 were byte-identical. `EnvironmentMock` takes the web management URL directly and defaults it to nil; only `LiveEnvironment` reads it out of the configuration, so 23 rendered the unconfigured screen. It now sets both. The list is genuinely the same either way — the row stays put, because removing it would strand a paying customer — so 24 now captures what the tap actually produces, which is where the two diverge. - 29 used `Entitlement(id:)`, a convenience init that hardcodes `store: .appStore`. That labelled the card App Store and sent the resolver down the App Store arm rather than the `.entitlementOnly` one written for comped grants. Passing `store: nil` renders the real thing. - Every screen with account details showed "December 31, 1969", because the mock's install date defaults to the epoch. Harmless in a unit test; in a design review it reads as a bug and costs the room a conversation. Also documents how to actually run this suite. The header claimed `CUSTOMER_CENTER_SNAPSHOT_DIR=... xcodebuild test` works; it doesn't, and neither does xcodebuild's `TEST_RUNNER_` prefix — neither reaches the test process in the simulator, and the suite skips in silence. The variable now sits on the scheme, off by default, so it survives xcodegen. Two comments corrected alongside: one asserted an `onDisappear` ordering Apple documents as view-type-dependent, the other argued against a `theme.background` that 00c2354 deleted. Co-Authored-By: Claude Opus 5 --- .../Views/CustomerCenterView.swift | 8 +-- .../xcschemes/SuperwallKit.xcscheme | 21 +++++++ .../CustomerCenterViewControllerTests.swift | 7 ++- .../Views/DesignReviewSnapshots.swift | 60 ++++++++++++++----- project.yml | 8 +++ 5 files changed, 82 insertions(+), 22 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift index 682cf5b6f1..48d3e4c3ef 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift @@ -189,10 +189,10 @@ public struct CustomerCenterView: View { // Opaque, so nothing shows through and no touch reaches a half-built screen. Matches the // `insetGrouped` list it becomes. // - // Deliberately not `theme.background`: nothing else in the SDK reads it, so honouring it - // here alone would tint the cover and then fade to an untinted list — a colour flip on - // exactly the screens that configured a background. It belongs here when background - // theming is wired up across the screens, not before. + // Deliberately not driven by a configured background colour: `Appearance` no longer + // exposes one, and honouring it here alone would tint the cover and then fade to an + // untinted list — a colour flip on exactly the screens that configured a background. + // It belongs here when background theming is wired up across the screens, not before. Color(uiColor: .systemGroupedBackground) ProgressView().accessibilityIdentifier("customer_center.loading") } diff --git a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme index 1fd7d5a1ab..4d1752fd7c 100644 --- a/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme +++ b/SuperwallKit.xcodeproj/xcshareddata/xcschemes/SuperwallKit.xcscheme @@ -57,6 +57,13 @@ isEnabled = "YES"> + + + + + + + + + + + + Date: Wed, 9 Sep 2026 12:24:14 -0500 Subject: [PATCH 53/64] fix(customer-center): stop a covered surface dismissing the visible one's sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isManagePresented`'s setter cleared `sheet` whatever it held, while its sibling nine lines below checked identity first. SwiftUI writes `false` into a boolean sheet binding whenever its getter goes false — which happens to every surface the moment something is pushed over it, not only when that surface's own sheet closes. So a covered screen could tear down the sheet the visible one had just opened and run `sheetDidDismiss()`, receipt refresh included, against a sheet that was never showing. Both setters now go through `CustomerCenterSheetOwnership.dismissalClears`, alongside the depth rule that was already there. The modifier and the two bindings are internal rather than private so the test can drive them. Testing the rule alone would have passed against the unguarded setter — which is exactly how this gate shipped inert once before — so the regression test writes `false` into the manage binding while the refund sheet is up and asserts the refund sheet survives. Verified it fails against the old setter before keeping it. Also captures design-review scenario 24 as sheet content rather than through `captureDetail`, which wrapped it in a `NavigationView` and drew a title bar the real page sheet does not have — hiding the point of the screenshot, which is that the sheet is one unstyled sentence. Co-Authored-By: Claude Opus 5 --- .../Views/CustomerCenterSheets.swift | 43 ++++++++++++++-- .../CustomerCenterSheetOwnershipTests.swift | 51 +++++++++++++++++++ .../Views/DesignReviewSnapshots.swift | 20 +++++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index cccde49d1f..6083eccc05 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -27,10 +27,34 @@ enum CustomerCenterSheetOwnership { static func isTopmost(surfaceDepth: Int, pushDepth: Int) -> Bool { surfaceDepth == pushDepth } + + /// The kind of sheet a boolean binding stands for. `CustomerCenterSheet`'s own cases carry + /// associated values, and a dismissal only needs to know which case it belongs to. + enum SheetKind { + case manageSubscriptions + case refund + } + + /// Whether a `false` write from a boolean sheet binding should clear `sheet`. + /// + /// SwiftUI writes `false` when the sheet that binding drives goes away — but it also writes it + /// when the *getter* flips false for another reason, which happens to every surface that stops + /// being topmost. Clearing unconditionally therefore lets a covered screen tear down the sheet + /// the visible one just opened, and run `sheetDidDismiss()` — receipt refresh included — + /// against a sheet that was never showing. Only the sheet actually up may clear itself. + static func dismissalClears(_ current: CustomerCenterSheet?, _ kind: SheetKind) -> Bool { + switch (current, kind) { + case (.manageSubscriptions, .manageSubscriptions), (.refund, .refund): return true + default: return false + } + } } +/// Internal rather than private so a test can drive the sheet bindings directly. The gate they +/// apply has been wrong twice — once inert, once over-eager — and both times the bug was in the +/// binding rather than in the rule it calls, which a test of the rule alone cannot catch. @available(iOS 15.0, *) -private struct CustomerCenterSheetsModifier: ViewModifier { +struct CustomerCenterSheetsModifier: ViewModifier { @ObservedObject var viewModel: CustomerCenterViewModel let surfaceDepth: Int @Environment(\.customerCenterStrings) private var strings @@ -51,22 +75,31 @@ private struct CustomerCenterSheetsModifier: ViewModifier { ) } - private var isManagePresented: Binding { + var isManagePresented: Binding { .init( get: { guard isTopmost, case .manageSubscriptions = viewModel.sheet else { return false } return true }, - set: { if !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } } + set: { + guard !$0, CustomerCenterSheetOwnership.dismissalClears(viewModel.sheet, .manageSubscriptions) else { + return + } + viewModel.sheet = nil + Task { await viewModel.sheetDidDismiss() } + } ) } - private var refundBinding: Binding { + var refundBinding: Binding { .init( get: { guard isTopmost, case .refund = viewModel.sheet else { return false } return true }, - set: { if !$0, case .refund = viewModel.sheet { viewModel.sheet = nil } } + set: { + guard !$0, CustomerCenterSheetOwnership.dismissalClears(viewModel.sheet, .refund) else { return } + viewModel.sheet = nil + } ) } private var itemSheet: Binding { diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift index fdb8fbf7ce..c8a137205b 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -78,6 +78,57 @@ struct CustomerCenterSheetOwnershipTests { #expect(!CustomerCenterSheetOwnership.isTopmost(surfaceDepth: 2, pushDepth: 1)) } + /// The asymmetry that made this rule necessary: SwiftUI writes `false` to a boolean sheet + /// binding whenever its getter goes false, which happens to every surface the moment something + /// is pushed over it — not only when that surface's own sheet is dismissed. An unconditional + /// clear therefore let a covered screen tear down the sheet the visible one had just opened, + /// and run `sheetDidDismiss()` against it. + @Test("only the sheet that is actually up may clear itself", arguments: [ + (CustomerCenterSheet.manageSubscriptions(groupId: nil), true, false), + (CustomerCenterSheet.refund(transactionId: 1, productId: "monthly_pro"), false, true), + (CustomerCenterSheet.survey(pathId: "cancel"), false, false), + (CustomerCenterSheet.webManageUnavailable, false, false) + ]) + func dismissalOnlyClearsItsOwnSheet( + current: CustomerCenterSheet, + clearsManage: Bool, + clearsRefund: Bool + ) { + #expect(CustomerCenterSheetOwnership.dismissalClears(current, .manageSubscriptions) == clearsManage) + #expect(CustomerCenterSheetOwnership.dismissalClears(current, .refund) == clearsRefund) + } + + @Test("a dismissal with no sheet up clears nothing") + func dismissalWithNothingPresentedClearsNothing() { + #expect(!CustomerCenterSheetOwnership.dismissalClears(nil, .manageSubscriptions)) + #expect(!CustomerCenterSheetOwnership.dismissalClears(nil, .refund)) + } + + /// The two above prove the rule; this proves the binding applies it. Testing only the rule + /// would pass just as happily against the unguarded setter that made it necessary. + @available(iOS 15.0, *) + @Test("a stale dismissal write does not tear down another surface's sheet") + func staleDismissalLeavesTheOpenSheetAlone() async { + let viewModel = makeViewModel() + let modifier = CustomerCenterSheetsModifier(viewModel: viewModel, surfaceDepth: 0) + + // A drill-down opens the refund sheet. The root's manage binding is still alive underneath. + viewModel.sheet = .refund(transactionId: 1, productId: "monthly_pro") + #expect(!modifier.isManagePresented.wrappedValue, "the manage sheet is not the one showing") + + // SwiftUI writes `false` into it, as it does to every binding whose getter goes false. + modifier.isManagePresented.wrappedValue = false + + #expect( + viewModel.sheet == .refund(transactionId: 1, productId: "monthly_pro"), + "the refund sheet must survive a dismissal meant for a sheet that was never up" + ) + + // And the binding that does own the sheet still clears it. + modifier.refundBinding.wrappedValue = false + #expect(viewModel.sheet == nil) + } + // MARK: - Driving the real navigator @available(iOS 15.0, *) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift index a6ff97de14..a836134fe1 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -307,6 +307,24 @@ struct DesignReviewSnapshots { } } + /// Captures the content of a `.sheet`. Deliberately not wrapped in a `NavigationView`: a page + /// sheet has no bar unless its content supplies one, so wrapping it would paint a title bar + /// production never shows — and hide the very thing the screenshot exists to reveal. + private func captureSheet( + _ name: String, + directory: URL, + viewModel: CustomerCenterViewModel, + @ViewBuilder content: () -> V + ) { + let view = content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground)) + .environment(\.customerCenterStrings, viewModel.strings) + for scheme in [ColorScheme.light, .dark] { + snapshot(view, named: name, colorScheme: scheme, directory: directory) + } + } + // MARK: - The screens @available(iOS 15.0, *) @@ -590,7 +608,7 @@ struct DesignReviewSnapshots { configuration: noWebURL, environment: EnvironmentMock(webManagementURL: nil) ) - captureDetail( + captureSheet( "24-web-subscription-no-management-url", directory: directory, viewModel: unconfigured From a9ad11dfbbdd69416ae835d268aebe6d549a1592 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 12:49:19 -0500 Subject: [PATCH 54/64] docs(customer-center): say which gate applies to which half of the binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment above `isTopmost` still read "only the getters are gated", three lines above two guarded setters. The distinction it is drawing is depth versus identity, so it now says that: getters gated on depth, setters on the sheet's identity, and the depth-gating hazard it documents spelled out as the reason for the split. The regression test pinned the negative case for the manage binding and the positive for refund, but never the positive for manage — the one that runs `sheetDidDismiss()`. It now clears a manage sheet from a surface that is no longer topmost, which is the "the owner may always clear" invariant the earlier depth-gated setter broke. Co-Authored-By: Claude Opus 5 --- .../CustomerCenter/Views/CustomerCenterSheets.swift | 9 +++++---- .../Views/CustomerCenterSheetOwnershipTests.swift | 8 ++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift index 6083eccc05..4f8c0454a8 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift @@ -64,10 +64,11 @@ struct CustomerCenterSheetsModifier: ViewModifier { /// stable — swapping modifiers mid-update is what stopped the manage sheet appearing once /// before. /// - /// Only the getters are gated. Gating the setters too would let a screen lose the right to - /// clear a sheet it already has open: the depth drops when the screen is popped, without regard - /// for whether a sheet is up, so the dismissal would be vetoed, `sheetDidDismiss()` would never - /// run, and the root would re-present the stale sheet the moment it became topmost again. + /// Only the getters are gated on depth. The setters are gated on the sheet's identity instead: + /// gating them on depth too would let a screen lose the right to clear a sheet it already has + /// open, since the depth drops when the screen is popped without regard for whether a sheet is + /// up — the dismissal would be vetoed, `sheetDidDismiss()` would never run, and the root would + /// re-present the stale sheet the moment it became topmost again. private var isTopmost: Bool { CustomerCenterSheetOwnership.isTopmost( surfaceDepth: surfaceDepth, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift index c8a137205b..c0c42809a7 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -127,6 +127,14 @@ struct CustomerCenterSheetOwnershipTests { // And the binding that does own the sheet still clears it. modifier.refundBinding.wrappedValue = false #expect(viewModel.sheet == nil) + + // The owner keeps that right once it is no longer topmost. The depth drops on a pop with no + // regard for whether a sheet is up, which is why the setters are gated on identity rather + // than depth — an earlier depth-gated setter is exactly what stranded a sheet here. + viewModel.pushDepth = 1 + viewModel.sheet = .manageSubscriptions(groupId: nil) + modifier.isManagePresented.wrappedValue = false + #expect(viewModel.sheet == nil, "the surface that opened a sheet must always be able to clear it") } // MARK: - Driving the real navigator From be729b84ed81e8a0b6f9a67623ed12de20874e19 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 12:58:48 -0500 Subject: [PATCH 55/64] fix(customer-center): three findings that each turned out to be real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A comped grant is a nil store, not a missing transaction.** The manage row was withheld from any `.entitlementOnly` purchase, on the assumption that no transaction means nothing to manage. But a web purchase arrives as a bare entitlement whenever the backend sends no matching transaction — and the only production constructors of `SubscriptionTransaction` and `NonSubscriptionTransaction` hardcode `store: .appStore`, so a paying Stripe or Paddle subscriber may only ever be `.entitlementOnly`. That took the row back off exactly the customers it was added for. It now keys on `entitlement.store == nil`, which is what "comped" actually means. The test that was meant to cover this couldn't fail: `Entitlement(id:)` hardcodes `store: .appStore`, so the purchase never reached the web branch and `manage == nil` came from the App Store branch instead — green with the rule deleted. It now passes `store: nil`, asserts the store it is testing, and has a sibling pinning the other half: a web entitlement with no transaction keeps its row. **A dismissed container is a dismissed Customer Center.** The pushed drill-down checked `isBeingDismissed`/`isMovingFromParent` on itself only, while the root controller walks the whole parent chain and documents why. So when a host presented a navigation controller holding a pushed Customer Center and dismissed it from a drill-down, UIKit marked the container, the drill-down read that as a cover, vetoed the debounce — and the root underneath, being covered, never got a `viewDidDisappear` to correct it. No dismissal was delivered at all. Both now share one check. **The update lookup's region.** `Locale.current.regionCode` is deprecated at iOS 16 and was read unguarded, while `DeviceHelper` guards the same property. It is also the device's region setting rather than the App Store storefront that decides which listing exists; the storefront would be better but reports three-letter codes this endpoint won't take, so the doc comment now says that instead of claiming otherwise, and the no-listing log names the region as the likely cause. Also covers the lookup itself. `defaults`, `session` and `now` existed on the initializer purely as test seams and nothing used them, so the 24h cache, the region query item and the non-2xx branch all shipped unexercised. A `URLProtocol` stub and a throwaway defaults suite reach all three without touching the network. Co-Authored-By: Claude Opus 5 --- .../Logic/AppStoreVersionLookup.swift | 27 +++- .../Logic/CustomerCenterPathResolver.swift | 11 +- .../UIKit/CustomerCenterPushNavigator.swift | 5 +- .../UIKit/CustomerCenterViewController.swift | 19 ++- .../Logic/AppStoreUpdateCheckTests.swift | 142 ++++++++++++++++++ .../Logic/WebSubscriptionPathTests.swift | 40 ++++- .../CustomerCenterSheetOwnershipTests.swift | 40 +++++ 7 files changed, 266 insertions(+), 18 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift index 6b00a4e3b0..73ff51f9c9 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift @@ -35,8 +35,14 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { 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. + /// Two-letter region to scope the lookup to. Versions differ by region during a phased release, + /// so asking for the wrong one can report a version this device can't install. + /// + /// This is the device's *region setting*, not the App Store storefront that actually decides + /// which listing exists — the two are configured independently. The storefront would be the + /// better source, but `Storefront.countryCode` is a three-letter code and this endpoint takes + /// two, with no first-party conversion between them. When they disagree and the app isn't + /// published in the device's region the lookup finds nothing, which hides the banner and logs. let regionCode: String? let defaults: UserDefaults let session: URLSession @@ -44,7 +50,7 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { init( bundleId: String? = Bundle.main.bundleIdentifier, - regionCode: String? = Locale.current.regionCode, + regionCode: String? = AppStoreVersionLookup.deviceRegionCode, defaults: UserDefaults = .standard, session: URLSession = .shared, now: @escaping () -> Date = Date.init @@ -56,6 +62,15 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { self.now = now } + /// Guarded rather than reading `Locale.regionCode` directly, which is deprecated at iOS 16 — + /// `DeviceHelper.regionCode` makes the same split. + static var deviceRegionCode: String? { + if #available(iOS 16, *) { + return Locale.autoupdatingCurrent.language.region?.identifier + } + return Locale.autoupdatingCurrent.regionCode + } + func latestAppStoreVersion() async -> String? { if let cached = cachedVersion() { return cached @@ -85,8 +100,10 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { 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." + message: "No App Store listing found for bundle id \(bundleId ?? "nil") " + + "in region \(regionCode ?? "none"). The update banner won't show. If the app is " + + "published elsewhere, that region is the likely reason. Set `latestAppVersion` to " + + "warn without a lookup." ) return nil } diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift index c740dfd42a..e88d27a9fc 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -81,10 +81,13 @@ enum CustomerCenterPathResolver { 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 { + // A comped grant — an entitlement with no transaction *and* no store behind it — has nothing + // to manage anywhere, so offer the page only if one exists and never claim a receipt was + // sent. The signal is the nil store, not the missing transaction: a web purchase arrives as a + // bare entitlement whenever the backend sends no matching transaction, and that entitlement + // still carries its store. Keying on the kind alone would take the row back off the paying + // customers it exists for. + if case .entitlementOnly(let entitlement) = purchase.kind, entitlement.store == nil { return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } } return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } ?? .webManageUnavailable diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift index 759ba2918c..a1b80f3239 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -89,7 +89,10 @@ private final class CustomerCenterPushedHostingController: UIHost override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - if isMovingFromParent || isBeingDismissed { + // The whole chain, not just `self`: when the host dismisses a navigation controller that holds + // the Customer Center, UIKit marks the container rather than this screen, and treating that as + // a cover would veto the dismissal for a Customer Center that is genuinely gone. + if isLeavingHierarchyOrAContainerIs { reportRemoval() return } diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 43cc21cc88..9a97308027 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -206,6 +206,23 @@ public final class CustomerCenterViewController: UIHostingController Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + if let url = request.url { + Self.requestedURLs.append(url) + } + let response = HTTPURLResponse( + url: request.url ?? URL(fileURLWithPath: "/"), + statusCode: Self.status, + httpVersion: nil, + headerFields: nil + ) + // swiftlint:disable:next force_unwrapping + client?.urlProtocol(self, didReceive: response!, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + } + + private func makeStubbedSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeDefaults() throws -> UserDefaults { + let name = "customer-center-tests-\(UUID().uuidString)" + return try #require(UserDefaults(suiteName: name)) + } + + @Test("scopes the lookup to the bundle id and region") + func lookupSendsBundleIdAndRegion() async throws { + StubURLProtocol.reset() + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "GB", + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + #expect(await lookup.latestAppStoreVersion() == "3.2.1") + + let url = try #require(StubURLProtocol.requestedURLs.first) + let items = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(items.contains(URLQueryItem(name: "bundleId", value: "com.acme.app"))) + #expect(items.contains(URLQueryItem(name: "country", value: "GB"))) + } + + /// A device with no region set must still get a lookup, rather than one scoped to an empty + /// country the endpoint would reject. + @Test("omits the region when there isn't one", arguments: [nil, ""]) + func lookupOmitsAnEmptyRegion(region: String?) async throws { + StubURLProtocol.reset() + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: region, + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + _ = await lookup.latestAppStoreVersion() + + let url = try #require(StubURLProtocol.requestedURLs.first) + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(!items.contains { $0.name == "country" }) + } + + @Test("a non-2xx response is not an answer", arguments: [404, 429, 500]) + func lookupIgnoresErrorResponses(status: Int) async throws { + StubURLProtocol.reset(status: status) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: try makeDefaults(), + session: makeStubbedSession() + ) + + #expect(await lookup.latestAppStoreVersion() == nil) + } + + @Test("a second check inside 24 hours is answered from the cache") + func lookupCachesWithinTheDay() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + var clock = Date(timeIntervalSince1970: 1_000_000) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: defaults, + session: session, + now: { clock } + ) + + #expect(await lookup.latestAppStoreVersion() == "3.2.1") + #expect(StubURLProtocol.requestedURLs.count == 1) + + // A day minus a minute later, and the answer on the wire has changed. The cache must win. + clock = clock.addingTimeInterval(AppStoreVersionLookup.cacheDuration - 60) + StubURLProtocol.body = Data(#"{"results":[{"version":"9.9.9"}]}"#.utf8) + #expect(await lookup.latestAppStoreVersion() == "3.2.1", "still inside the cache window") + #expect(StubURLProtocol.requestedURLs.count == 1, "and no second request was made") + } + + @Test("the cache expires after 24 hours") + func lookupRefetchesAfterTheDay() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + var clock = Date(timeIntervalSince1970: 1_000_000) + let lookup = AppStoreVersionLookup( + bundleId: "com.acme.app", + defaults: defaults, + session: session, + now: { clock } + ) + + _ = await lookup.latestAppStoreVersion() + clock = clock.addingTimeInterval(AppStoreVersionLookup.cacheDuration + 1) + StubURLProtocol.body = Data(#"{"results":[{"version":"9.9.9"}]}"#.utf8) + + #expect(await lookup.latestAppStoreVersion() == "9.9.9") + #expect(StubURLProtocol.requestedURLs.count == 2) + } + // MARK: - Configuration round trip @Test("configuration written before the flag existed still decodes") diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift index 5d4ddd3e19..79058285f7 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift @@ -111,17 +111,12 @@ 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 { + private func makeEntitlementOnlyViewModel(store: EntitlementStore?) async -> CustomerCenterViewModel { let (deps, _, _) = CustomerCenterDependencies.mock( info: CustomerInfo( subscriptions: [], nonSubscriptions: [], - entitlements: [Entitlement(id: "pro")] + entitlements: [Entitlement(id: "pro", store: store)] ), environment: EnvironmentMock(webManagementURL: nil) ) @@ -131,12 +126,43 @@ struct WebSubscriptionPathTests { strings: .english ) await viewModel.load() + return viewModel + } + + /// An entitlement with no transaction *and* no store behind it — comped, or granted by hand — + /// is reported by the builder as `.superwall`, which reads as a web store. Sending that customer + /// to a management page, or telling them to find a link in a receipt they never got, is wrong. + /// + /// `Entitlement(id:)` is no help here: the public convenience initializer hardcodes + /// `store: .appStore`, so the purchase never reaches the web branch and the case passes on the + /// App Store branch's `guard let sub` instead — green with this rule deleted. + @available(iOS 15.0, *) + @Test("a comped entitlement isn't told to check a receipt it never had") + func compedEntitlementGetsNoReceiptBlurb() async { + let viewModel = await makeEntitlementOnlyViewModel(store: nil) let purchase = viewModel.purchases.first + #expect(purchase?.store == .superwall, "otherwise this never reaches the branch under test") let manage = viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } #expect(manage == nil, "nothing to manage, so no row at all") } + /// The other half of that rule, and the reason it keys on the store rather than the kind: a web + /// purchase arrives as a bare entitlement whenever the backend sends no matching transaction. + /// Those customers are paying, and the row telling them where the link is has to survive. + @available(iOS 15.0, *) + @Test("a web purchase with no transaction behind it keeps its management row", arguments: [ + EntitlementStore.stripe, .paddle + ]) + func webEntitlementWithoutATransactionKeepsTheRow(store: EntitlementStore) async { + let viewModel = await makeEntitlementOnlyViewModel(store: store) + + let purchase = viewModel.purchases.first + let manage = viewModel.paths(for: purchase).first { $0.path.type == .manageSubscription } + #expect(manage != nil, "a paying customer must still be told where to manage this") + #expect(manage?.destination == .webManageUnavailable) + } + // 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 diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift index c0c42809a7..2e600a4a85 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterSheetOwnershipTests.swift @@ -231,4 +231,44 @@ struct CustomerCenterSheetOwnershipTests { window.isHidden = true } + + /// UIKit marks the controller it is directly removing, not the screens inside it. When a host + /// presents a navigation controller holding a pushed Customer Center and dismisses it while the + /// user is on a drill-down, nothing on the drill-down itself is set — so a check of `self` alone + /// reads a real teardown as a cover, vetoes the debounce, and delivers no dismissal at all. The + /// root underneath is covered, so it never gets a `viewDidDisappear` of its own to correct it. + @available(iOS 15.0, *) + @Test("dismissing a container the Customer Center sits inside is a teardown, not a cover") + func dismissingTheContainingNavigationControllerDismisses() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let viewModel = makeViewModel(delegate: delegate, dismissDebounceInterval: debounce) + let host = UIViewController() + let navigation = DismissingNavigationController(rootViewController: host) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + spinRunLoop(timeout: 1) { host.viewIfLoaded?.window != nil } + + let navigator = CustomerCenterPushNavigator(viewModel: viewModel) + navigator.presenter = host + navigator.push(Text("purchase history")) + spinRunLoop(timeout: 2) { navigation.viewControllers.last?.viewIfLoaded?.window != nil } + + // The drill-down disappears because the container around it is being dismissed. SwiftUI has + // armed the debounce; nothing must cancel it. + viewModel.surfaceDidDisappear() + navigation.viewControllers.last?.viewDidDisappear(false) + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 1, "a dismissed container is a dismissed Customer Center") + + window.isHidden = true + } +} + +/// Stands in for a navigation controller the host has presented and is now dismissing. +/// `isBeingDismissed` is read-only, and a hostless test target never drives a modal transition to +/// completion, so the one fact UIKit would report is supplied directly. +private final class DismissingNavigationController: UINavigationController { + override var isBeingDismissed: Bool { true } } From 7841bc130cb2f12b958e67d8a3ebec8a22761a0d Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 13:07:09 -0500 Subject: [PATCH 56/64] fix(customer-center): close the veto's ordering race, and stop three comments overclaiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The veto assumed an ordering it doesn't control.** Being covered cancelled a pending dismissal, which only works if SwiftUI has already delivered `onDisappear` by the time `viewDidDisappear` runs. Apple documents that moment as depending on the view type and ties it to no UIKit callback — arriving a runloop turn later, the veto found nothing to cancel, the debounce armed unopposed, and the premature `didDismiss` fired and latched, silencing the real teardown. It now suppresses until the next appearance, which covers both orderings. Genuine teardowns are unaffected: the controllers deliver those through `dismiss()` directly. The existing test armed the debounce before the veto — the easy ordering, which passes either way. It keeps that case and gains the one production can't guarantee, verified to fail against the cancel-only veto. **The catalogue gap-fill had no test that reached it.** Both pricing tests built `ProductDisplayInfo` by hand, so deleting the whole gap-fill left them green: what they pinned was that `APIStoreProduct` yields a usable price, not that the provider ever asks for one. The rule is now a static `fillingGaps(in:requested:from:)` — `products(for:)` itself reaches `Superwall.shared` and the container's network, so the rule is as close as a test can get — and five cases cover it, including the one that matters for money: an App Store product StoreKit failed to return is never filled from the catalogue, whose price is the dashboard's storefront rather than the customer's. **Comments.** The locale note named `SuperwallOptions.localeIdentifier` in two places; `environment.locale` is `DeviceHelper.preferredLocaleIdentifier`, the device's preferred language, and the option only applies when there are no preferred languages at all. The `load()` ordering comment implied open always precedes close, which holds for the App Store lookup but not for a customer who closes while `apply` is still awaiting StoreKit. **CHANGELOG.** Four entries described fixes to the Customer Center under the release that introduces it, so they read as fixes to something nobody has shipped against — and one of them repeated the locale claim above. Dropped; the two genuine fixes to shipped behaviour stay. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 - .../Logic/PurchasePresentationBuilder.swift | 4 +- .../UIKit/CustomerCenterPushNavigator.swift | 2 +- .../UIKit/CustomerCenterViewController.swift | 2 +- .../CustomerCenterDependencies.swift | 40 +++++--- .../ViewModel/CustomerCenterViewModel.swift | 32 +++++- .../Logic/WebProductPricingTests.swift | 98 ++++++++++++++++++- .../CustomerCenterViewControllerTests.swift | 43 +++++++- 8 files changed, 191 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc967d7033..c6ae41f7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,6 @@ 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 d741937092..fa357b16bd 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -21,8 +21,8 @@ struct PurchasePresentationBuilder { ) { self.now = now self.strings = strings - // Dates must follow the same locale as the strings (`SuperwallOptions.localeIdentifier` via - // `CustomerCenterEnvironmentProviding.locale`), not the system locale. + // Dates must follow the same locale as the strings — `DeviceHelper.preferredLocaleIdentifier` + // via `CustomerCenterEnvironmentProviding.locale` — not the system locale. self.dateFormatter = dateFormatter ?? { let formatter = DateFormatter() formatter.dateStyle = .medium diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift index a1b80f3239..fd4476eb7e 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift @@ -52,7 +52,7 @@ final class CustomerCenterPushNavigator: CustomerCenterNavigating { self.viewModel.pushDepth = min(self.viewModel.pushDepth, depth - 1) } controller.onCoveredWhileStillInStack = { [weak self] in - self?.viewModel.cancelPendingDismissal() + self?.viewModel.suppressDismissalUntilNextAppearance() } viewModel.pushDepth = depth navigationController.pushViewController(controller, animated: true) diff --git a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift index 9a97308027..d03a805aa2 100644 --- a/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift +++ b/Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift @@ -160,7 +160,7 @@ public final class CustomerCenterViewController: UIHostingController, + from catalogue: [SuperwallProduct] + ) -> [String: ProductDisplayInfo] { + let missing = ids.subtracting(resolved.keys) + guard !missing.isEmpty else { return resolved } + var filled = resolved + for product in catalogue + where missing.contains(product.identifier) && product.platform != .ios { + let entitlements = Set(product.entitlements.map { Entitlement(id: $0.identifier) }) + let apiProduct = APIStoreProduct(superwallProduct: product, entitlements: entitlements) + let storeProduct = StoreProduct(catalogProduct: apiProduct) + filled[product.identifier] = ProductDisplayInfo(storeProduct, name: product.name) + } + return filled + } + /// How long the catalogue gets before the screen gives up on prices and renders without them. private static let catalogueTimeout: TimeInterval = 5 diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 8b10d1c7f9..47aeb24d28 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -36,8 +36,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. + /// Locale for date formatting, matching the locale the localized strings resolve against: + /// `DeviceHelper.preferredLocaleIdentifier`, the device's preferred language. Not + /// `SuperwallOptions.localeIdentifier`, which applies only when there are no preferred + /// languages at all. var locale: Locale { dependencies.environment.locale } // Not `private`: the support-email extension in `CustomerCenterViewModel+Support.swift` @@ -70,6 +72,9 @@ final class CustomerCenterViewModel: ObservableObject { /// reaches zero and stays zero past the debounce, the Customer Center is genuinely gone. private var visibleSurfaceCount = 0 private var dismissDebounceTask: Task? + /// Set by a host that knows this disappearance is a cover rather than a teardown. Cleared the + /// next time a surface appears. See ``suppressDismissalUntilNextAppearance()``. + private var isDismissalSuppressed = false init( configuration: CustomerCenterConfiguration, @@ -118,8 +123,12 @@ 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. + // render and the open event — must not wait on it. + // + // That orders open before the *lookup*, not before every close: `apply` above still awaits a + // StoreKit round trip, so closing while the spinner is up can still emit close first. Moving + // open ahead of `apply` would fix that and put the whole tracking pipeline in front of first + // paint, which is the worse trade. await refreshAppStoreVersion() } @@ -332,6 +341,7 @@ extension CustomerCenterViewModel { /// tracks nested pushes correctly. Also cancels any pending dismissal from a prior disappear. func surfaceDidAppear() { visibleSurfaceCount += 1 + isDismissalSuppressed = false dismissDebounceTask?.cancel() dismissDebounceTask = nil } @@ -343,7 +353,7 @@ extension CustomerCenterViewModel { /// debounce elapses cancels it. func surfaceDidDisappear() { visibleSurfaceCount = max(0, visibleSurfaceCount - 1) - guard visibleSurfaceCount == 0 else { return } + guard visibleSurfaceCount == 0, !isDismissalSuppressed else { return } dismissDebounceTask?.cancel() // Captures self strongly: on the SwiftUI sheet path the last `onDisappear` is immediately // followed by `@StateObject` releasing the view model, and a weak capture would let it @@ -366,6 +376,18 @@ extension CustomerCenterViewModel { dismissDebounceTask = nil } + /// The same veto, proof against the ordering it can't control. + /// + /// ``cancelPendingDismissal()`` only drops an already-armed dismissal, assuming SwiftUI has + /// delivered `onDisappear` by the time `viewDidDisappear` runs — a moment Apple documents as + /// view-type-dependent. Arriving a runloop turn later, it found nothing to cancel and armed + /// unopposed. Suppressing until the next appearance covers both orderings; a genuine teardown + /// is unaffected, since the controllers deliver those through ``dismiss()`` directly. + func suppressDismissalUntilNextAppearance() { + isDismissalSuppressed = true + cancelPendingDismissal() + } + func dismiss() { guard !didDismiss else { return } didDismiss = true diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift index 8b2d806439..cb67023b30 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -16,15 +16,17 @@ struct WebProductPricingTests { private func decodeProduct( amountInCents: Int, currency: String = "USD", - name: String? = nil + name: String? = nil, + identifier: String = "web_pro_monthly", + platform: String = "stripe" ) throws -> SuperwallProduct { let nameField = name.map { "\"name\": \"\($0)\"," } ?? "" let json = """ { "object": "product", - "identifier": "web_pro_monthly", + "identifier": "\(identifier)", \(nameField) - "platform": "stripe", + "platform": "\(platform)", "price": { "amount": \(amountInCents), "currency": "\(currency)" }, "subscription": { "period": "month", @@ -93,8 +95,9 @@ struct WebProductPricingTests { #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. + /// The field the backend hasn't shipped yet. Pins that `name` decodes off the payload and that + /// `ProductDisplayInfo` honours it; the call site that passes it through is covered separately, + /// by `catalogueNameReachesTheCard` below. @Test("uses the catalogue's display name as soon as the payload carries one") func usesDisplayNameWhenPresent() throws { let product = try decodeProduct(amountInCents: 999, name: "Pro") @@ -126,4 +129,89 @@ struct WebProductPricingTests { let display = ProductDisplayInfo(storeProduct) #expect(display.price == 0) } + + // MARK: - The rule that decides what the catalogue is allowed to fill + + private func displayInfo(for identifier: String) throws -> ProductDisplayInfo { + let product = try decodeProduct(amountInCents: 100, identifier: identifier) + return ProductDisplayInfo(StoreProduct(catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []))) + } + + @Test("fills only the products StoreKit couldn't resolve") + func fillsOnlyTheGaps() throws { + let fromStoreKit = try displayInfo(for: "ios_pro_monthly") + let catalogue = [ + try decodeProduct(amountInCents: 999, identifier: "web_pro_monthly"), + try decodeProduct(amountInCents: 500, identifier: "web_unrelated") + ] + + let filled = LiveProductsProvider.fillingGaps( + in: ["ios_pro_monthly": fromStoreKit], + requested: ["ios_pro_monthly", "web_pro_monthly"], + from: catalogue + ) + + #expect(filled.keys.sorted() == ["ios_pro_monthly", "web_pro_monthly"]) + #expect(filled["web_pro_monthly"]?.localizedPrice?.contains("9.99") == true) + #expect(filled["web_unrelated"] == nil, "a catalogue entry nobody asked about is not a gap") + } + + /// The restriction that matters for money. `products(for:)` swallows a failed StoreKit lookup + /// with `try?`, so an App Store product can land in the gap — and the catalogue holds the + /// dashboard's storefront price, not what this customer is actually charged in theirs. + @Test("never fills an App Store product from the catalogue") + func neverFillsAppStoreProducts() throws { + let catalogue = [try decodeProduct(amountInCents: 999, identifier: "ios_pro_monthly", platform: "ios")] + + let filled = LiveProductsProvider.fillingGaps( + in: [:], + requested: ["ios_pro_monthly"], + from: catalogue + ) + + #expect(filled.isEmpty, "better no price than a price from the wrong storefront") + } + + @Test("what StoreKit resolved is never overwritten") + func storeKitWins() throws { + let fromStoreKit = try displayInfo(for: "web_pro_monthly") + let catalogue = [try decodeProduct(amountInCents: 9_999, identifier: "web_pro_monthly")] + + let filled = LiveProductsProvider.fillingGaps( + in: ["web_pro_monthly": fromStoreKit], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled["web_pro_monthly"]?.localizedPrice == fromStoreKit.localizedPrice) + } + + /// The other half of `usesDisplayNameWhenPresent`: that the call site actually passes the name + /// through. Deleting `name:` from `fillingGaps` leaves that test green and fails this one. + @Test("the catalogue's name reaches the card") + func catalogueNameReachesTheCard() throws { + let catalogue = [try decodeProduct(amountInCents: 999, name: "Pro")] + + let filled = LiveProductsProvider.fillingGaps( + in: [:], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled["web_pro_monthly"]?.title == "Pro") + } + + @Test("nothing missing means nothing to do") + func noGapsNoWork() throws { + let fromStoreKit = try displayInfo(for: "web_pro_monthly") + let catalogue = [try decodeProduct(amountInCents: 9_999, identifier: "other")] + + let filled = LiveProductsProvider.fillingGaps( + in: ["web_pro_monthly": fromStoreKit], + requested: ["web_pro_monthly"], + from: catalogue + ) + + #expect(filled.count == 1) + } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift index 307feaac52..3359dcabcc 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift @@ -115,11 +115,8 @@ struct CustomerCenterViewControllerTests { spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } // Arm the debounce explicitly rather than trusting SwiftUI's `onDisappear` to have fired in - // this harness. The claim under test is that the veto cancels an *armed* dismissal, and with - // nothing armed the test would pass whether or not the veto works at all. This ordering is - // what the veto assumes in production — that `super.viewDidDisappear` forwards the - // disappearance into SwiftUI before the guard below runs — which Apple documents as - // view-type-dependent rather than guaranteed. + // this harness: with nothing armed the test would pass whether or not the veto works at all. + // Armed *before* the veto is the ordering the veto assumes, and the easier of the two. controller.viewModel.surfaceDidDisappear() controller.viewDidDisappear(false) @@ -135,6 +132,42 @@ struct CustomerCenterViewControllerTests { window.isHidden = true } + /// The ordering the veto cannot assume. Apple documents `onDisappear`'s exact moment as + /// depending on the view type and ties it to no UIKit callback, so SwiftUI may deliver it a + /// runloop turn after `viewDidDisappear` returns — arming the debounce with nothing left to + /// cancel it. A veto that only drops an already-armed dismissal passes the test above and fires + /// the premature `didDismiss` here, latching and silencing the genuine teardown afterwards. + @available(iOS 15.0, *) + @Test("pushed: a cover survives SwiftUI reporting the disappearance late") + func pushedCoverSurvivesALateDisappearance() async { + let debounce: TimeInterval = 0.2 + let delegate = ProbeDelegate() + let controller = makeController(style: .pushed, delegate: delegate, dismissDebounceInterval: debounce) + + let navigation = UINavigationController(rootViewController: UIViewController()) + let window = makeWindow(rootViewController: navigation) + window.makeKeyAndVisible() + navigation.pushViewController(controller, animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window != nil } + + navigation.pushViewController(UIViewController(), animated: false) + spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil } + + // The veto runs first, and only then does SwiftUI get round to reporting the disappearance. + controller.viewDidDisappear(false) + controller.viewModel.surfaceDidDisappear() + + try? await Task.sleep(nanoseconds: UInt64(debounce * 4 * 1_000_000_000)) + #expect(delegate.didDismissCount == 0, "a late disappearance must not resurrect the dismissal") + + // And the real teardown still lands, which the premature fire would have latched away. + navigation.popToRootViewController(animated: false) + spinRunLoop(timeout: 1) { delegate.didDismissCount > 0 } + #expect(delegate.didDismissCount == 1) + + window.isHidden = true + } + @available(iOS 15.0, *) @Test("pushed: being popped off the host's stack fires the dismissal exactly once") func pushedPopFiresDismissal() { From c473247bfa90bf9f7377c2106d9f7b0e65f2b327 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Wed, 9 Sep 2026 13:09:52 -0500 Subject: [PATCH 57/64] perf(customer-center): stop refetching the catalogue on every screen change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apply(customerInfo:refetchProducts:)` runs on load, on every restore and after every sheet dismissal, and all four call sites refetch — so a web-store customer paid for the whole Superwall catalogue several times inside a single visit, for a product list that changes on the dashboard's timescale rather than theirs. The 5s bound added earlier caps how long each of those can hurt; it doesn't stop them happening. A short-lived cache in front of the fetch: long enough to cover one visit, short enough that a price edit shows up the next time anyone opens the screen. Failures aren't cached — the cards render without prices and the next `apply` should try again rather than serve the failure for five minutes. Co-Authored-By: Claude Opus 5 --- .../CustomerCenterDependencies.swift | 45 ++++++++++++- SuperwallKit.xcodeproj/project.pbxproj | 4 ++ .../Logic/CatalogueCacheTests.swift | 66 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 57ad25dbb4..8916406ac4 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -132,8 +132,10 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { // 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() + let response = try await CatalogueCache.shared.products { + try await withCatalogueTimeout { + try await container.network.getSuperwallProducts() + } } resolved = Self.fillingGaps(in: resolved, requested: ids, from: response.data) } catch { @@ -179,7 +181,7 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { /// 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( + func withCatalogueTimeout( _ work: @escaping () async throws -> SuperwallProductsResponse ) async throws -> SuperwallProductsResponse { try await withThrowingTaskGroup(of: SuperwallProductsResponse.self) { group in @@ -196,6 +198,43 @@ struct LiveProductsProvider: CustomerCenterProductsProviding { } } } +/// Holds the Superwall catalogue for a short while. +/// +/// `apply(customerInfo:refetchProducts:)` runs on load, on every restore, and after every sheet +/// dismissal, and all four call sites refetch — so without this a web-store customer pays for the +/// catalogue several times inside one visit, for a product list that changes on the dashboard's +/// timescale rather than the customer's. The window is deliberately short: long enough to cover a +/// single visit, short enough that a price edit shows up the next time anyone opens the screen. +@available(iOS 15.0, *) +actor CatalogueCache { + static let shared = CatalogueCache() + static let ttl: TimeInterval = 5 * 60 + + private var cached: (response: SuperwallProductsResponse, at: Date)? + private let now: () -> Date + + init(now: @escaping () -> Date = Date.init) { + self.now = now + } + + /// Returns the cached catalogue when it is still fresh, otherwise awaits `fetch` and keeps it. + /// A throwing `fetch` is not cached — a failed load should be retried, not remembered. + func products( + fetch: () async throws -> SuperwallProductsResponse + ) async throws -> SuperwallProductsResponse { + if let cached, isFresh(cached.at) { + return cached.response + } + let response = try await fetch() + cached = (response, now()) + return response + } + + func isFresh(_ date: Date) -> Bool { + now().timeIntervalSince(date) < Self.ttl + } +} + @available(iOS 15.0, *) struct LiveRestorer: CustomerCenterRestoring { func restorePurchases() async -> RestorationResult { diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 37ed0f2716..0e454c1496 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -317,6 +317,7 @@ 8537CA38FFD40CF7C8A6A691 /* CustomStoreProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; + 85F77E1A644AC7049D341D31 /* CatalogueCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */; }; 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; @@ -1183,6 +1184,7 @@ C41A0BC4F44E339BBCA0D9F0 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; C4961C52D6005392183A0277 /* LocationManagerProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManagerProxyTests.swift; sourceTree = ""; }; C567965E23812D8C25869C19 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CatalogueCacheTests.swift; sourceTree = ""; }; C5B5BF873B8D190097E8CFB5 /* PriceFormatterProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceFormatterProvider.swift; sourceTree = ""; }; C65CEB049E29538C699F6EF8 /* PaywallPresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationInfo.swift; sourceTree = ""; }; C66CFEB3004DF2C3C3DB44FF /* CustomStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomStoreProduct.swift; sourceTree = ""; }; @@ -2040,6 +2042,7 @@ children = ( 90A67AE3E66A70518CCB3B4F /* AppStoreUpdateCheckTests.swift */, 016DD542BBB840B80C9A9BF4 /* AppVersionComparatorTests.swift */, + C59DDA518AEFEE718D936B39 /* CatalogueCacheTests.swift */, 45B62967CEF47D4315E4A3EF /* CustomerCenterPathResolverTests.swift */, 4032D6E844683EBEFB6FF619 /* PurchasePresentationBuilderTests.swift */, 501D9B961F52A9BB0494BA5A /* SupportEmailComposerTests.swift */, @@ -3585,6 +3588,7 @@ B0DC8290B081B74CC65E9305 /* CELEvaluatorTests.swift in Sources */, E984458E465D5A834CC52302 /* CacheMock.swift in Sources */, CBFC0D2DCA996A5FF7E5174B /* CacheTests.swift in Sources */, + 85F77E1A644AC7049D341D31 /* CatalogueCacheTests.swift in Sources */, 2A07A8F4A55E28D13777D03E /* CheckDebuggerPresentationOperatorTests.swift in Sources */, A73497BB3DCD881318A5CD86 /* ConfigLogicTests.swift in Sources */, 63CED0C62636A9FD03B7315A /* ConfigManagerMock.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift new file mode 100644 index 0000000000..7116c8de7b --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift @@ -0,0 +1,66 @@ +// +// CatalogueCacheTests.swift +// +// +// Created by Jordan Morgan on 09/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Catalogue cache") +struct CatalogueCacheTests { + private func response() -> SuperwallProductsResponse { + let json = #"{"data":[],"object":"list","has_more":false}"# + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(SuperwallProductsResponse.self, from: Data(json.utf8)) + } + + /// The reason this exists: `apply(customerInfo:refetchProducts:)` runs on load, on every restore + /// and after every sheet dismissal, and all four call sites refetch. + @available(iOS 15.0, *) + @Test("a second visit inside the window doesn't refetch") + func cachesWithinTheWindow() async throws { + var clock = Date(timeIntervalSince1970: 1_000_000) + let cache = CatalogueCache { clock } + var fetches = 0 + + _ = try await cache.products { fetches += 1; return response() } + clock = clock.addingTimeInterval(CatalogueCache.ttl - 1) + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 1) + } + + @available(iOS 15.0, *) + @Test("the cache expires, so a price edit shows up") + func expiresAfterTheWindow() async throws { + var clock = Date(timeIntervalSince1970: 1_000_000) + let cache = CatalogueCache { clock } + var fetches = 0 + + _ = try await cache.products { fetches += 1; return response() } + clock = clock.addingTimeInterval(CatalogueCache.ttl + 1) + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 2) + } + + /// A failed load must not be remembered: the cards render without prices and the next `apply` + /// should try again, rather than serving the failure for five minutes. + @available(iOS 15.0, *) + @Test("a failure isn't cached") + func doesNotCacheFailures() async throws { + struct Boom: Error {} + let cache = CatalogueCache() + var fetches = 0 + + await #expect(throws: Boom.self) { + _ = try await cache.products { fetches += 1; throw Boom() } + } + _ = try await cache.products { fetches += 1; return response() } + + #expect(fetches == 2) + } +} From 92a2c20e5e0f648d0d70d6a5e27af5fee05b0e2d Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Thu, 10 Sep 2026 14:33:40 -0500 Subject: [PATCH 58/64] feat(customer-center): make a URL path name its own row, and close two partial fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`.url` now requires a title.** It was the one path type with no sensible default: every other type has a fixed meaning and a localized label, while a URL could be anything. The fallback was `url.host`, which keeps the part of a URL that is identical across an app's own links and throws away the part that says what the page is — so FAQ, terms and privacy all rendered as three identical rows reading "example.com". There is no better derivation, because the developer is the only one who knows what to call it, so the case now carries the title and the compiler asks for it. The ObjC factory's `title` goes non-optional for the same reason. Breaking, and deliberately taken now: the Customer Center hasn't shipped, so this is free today and wouldn't be after 4.17.0. `PathsListView.title(for:strings:)` is static so the rule can be tested — a title that reads the same for two different paths is only visible by comparing two of them, which a rendered view can't easily be asked about. The regression test is exactly that: three URLs on one host, three distinct rows. **The update lookup's cache is keyed on region.** It held one answer keyed on time alone, so changing device region served the previous store's version for the rest of the day — precisely when it is most likely wrong. **The web management row now gates on state.** The App Store branch checks that a subscription is live; the web branch checked only the store, so a one-off Stripe charge with no subscription behind it and a subscription that had already lapsed or been revoked were each offered a "Manage subscription" row. Also serializes `AppStoreUpdateCheckTests`, whose `URLProtocol` stub holds static state that eight tests clobbered under parallel execution — the failure currently red on CI. Co-Authored-By: Claude Opus 5 --- Examples/Advanced/Advanced/HomeView.swift | 2 +- .../Logic/AppStoreVersionLookup.swift | 8 +- .../Logic/CustomerCenterPathResolver.swift | 12 ++- .../Models/CustomerCenterAction.swift | 2 +- .../CustomerCenterConfiguration+ObjC.swift | 10 ++- .../Models/CustomerCenterConfiguration.swift | 6 +- .../CustomerCenter/Views/PathsListView.swift | 9 ++- .../Documentation.docc/CustomerCenter.md | 11 ++- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../Logic/AppStoreUpdateCheckTests.swift | 38 +++++++++- .../CustomerCenterPathResolverTests.swift | 4 +- .../Logic/WebSubscriptionPathTests.swift | 63 ++++++++++++++-- .../Models/CustomerCenterActionTests.swift | 2 +- .../CustomerCenterConfigurationTests.swift | 2 +- .../CustomerCenterViewModelTests.swift | 4 +- .../CustomerCenter/Views/PathTitleTests.swift | 74 +++++++++++++++++++ 16 files changed, 226 insertions(+), 25 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift diff --git a/Examples/Advanced/Advanced/HomeView.swift b/Examples/Advanced/Advanced/HomeView.swift index 76035d0230..55370d4cef 100644 --- a/Examples/Advanced/Advanced/HomeView.swift +++ b/Examples/Advanced/Advanced/HomeView.swift @@ -40,7 +40,7 @@ struct HomeView: View { .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: "faq", type: .url(URL(string: "https://superwall.com/faq")!, title: "FAQ", openMethod: .inApp)), .init(id: "contact_support", type: .contactSupport) ] ), diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift index 73ff51f9c9..a3098fe849 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift @@ -33,6 +33,7 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { private static let versionKey = "com.superwall.customerCenter.latestAppStoreVersion" private static let fetchedAtKey = "com.superwall.customerCenter.latestAppStoreVersionFetchedAt" + private static let regionKey = "com.superwall.customerCenter.latestAppStoreVersionRegion" let bundleId: String? /// Two-letter region to scope the lookup to. Versions differ by region during a phased release, @@ -147,7 +148,11 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { guard let version = defaults.string(forKey: Self.versionKey), let fetchedAt = defaults.object(forKey: Self.fetchedAtKey) as? Date, - now().timeIntervalSince(fetchedAt) < Self.cacheDuration + now().timeIntervalSince(fetchedAt) < Self.cacheDuration, + // A version is only the answer for the region it was fetched for. Without this, someone who + // changes region is served the previous store's answer for the rest of the day — and that + // is exactly when the answer is most likely to be wrong. + defaults.string(forKey: Self.regionKey) == regionCode else { return nil } @@ -157,5 +162,6 @@ struct AppStoreVersionLookup: CustomerCenterAppStoreVersionProviding { private func cache(_ version: String) { defaults.set(version, forKey: Self.versionKey) defaults.set(now(), forKey: Self.fetchedAtKey) + defaults.set(regionCode, forKey: Self.regionKey) } } diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift index e88d27a9fc..8543bc3217 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift @@ -81,6 +81,16 @@ enum CustomerCenterPathResolver { guard [.stripe, .paddle, .superwall].contains(purchase.store) else { return nil } + // A one-off purchase has nothing to manage, and neither has a subscription that has lapsed or + // been revoked. The App Store branch above gates on both; this one gated on neither, so a + // single Stripe charge and an expired web subscription were each offered a management row. + switch purchase.kind { + case .nonSubscription: return nil + case .subscription(let sub) where sub.isRevoked: return nil + default: break + } + guard purchase.isActive else { return nil } + // A comped grant — an entitlement with no transaction *and* no store behind it — has nothing // to manage anywhere, so offer the page only if one exists and never claim a receipt was // sent. The signal is the nil store, not the missing transaction: a web purchase arrives as a @@ -112,7 +122,7 @@ enum CustomerCenterPathResolver { case .contactSupport: return context.supportEmailAvailable && context.canOpenURLs ? .contactSupport : nil - case let .url(url, method): + 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) diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift index 9381349a5c..0cc6815d09 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterAction.swift @@ -24,7 +24,7 @@ public enum CustomerCenterAction: Equatable, Sendable { case .refund: self = .refund case .changePlan: self = .changePlan case .contactSupport: self = .contactSupport - case .url(let url, _): self = .url(url) + case .url(let url, _, _): self = .url(url) case .custom(let identifier): self = .custom(identifier: identifier) } } diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift index b2de070bc9..6f8fb73301 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift @@ -32,11 +32,11 @@ extension CustomerCenterConfiguration.Path { } } @objc public var url: URL? { - if case .url(let url, _) = type { return 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 } + if case .url(_, _, let method) = type, method == .external { return .external } return .inApp } @objc public var customIdentifier: String? { @@ -67,8 +67,10 @@ extension CustomerCenterConfiguration.Path { @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) + /// `title` is non-optional here, unlike the other path factories: a URL row has no default + /// name to fall back on. + @objc public static func url(id: String, url: URL, openMethod: CustomerCenterOpenMethodObjc, title: String) -> CustomerCenterConfiguration.Path { + .init(id: id, type: .url(url, title: title, openMethod: openMethod == .external ? .external : .inApp)) } @objc public static func custom(id: String, identifier: String, title: String?) -> CustomerCenterConfiguration.Path { .init(id: id, type: .custom(identifier: identifier), title: title) diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index ce26601634..dc88401a21 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -183,7 +183,11 @@ public final class CustomerCenterConfiguration: NSObject, Codable { /// `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) + /// `title`: what the row says. Required, because it is the one path type the SDK cannot name + /// for you — every other type has a fixed meaning and a localized default, while a URL could + /// be anything. Deriving a label from the URL doesn't work: the host is identical across an + /// app's own links, so FAQ, terms and privacy would all render as one repeated row. + case url(URL, title: String, openMethod: OpenMethod) case custom(identifier: String) } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift index 28386739cf..7eb8327cb2 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -44,6 +44,13 @@ struct PathsListView: View { } private func title(for resolved: ResolvedPath) -> String { + Self.title(for: resolved, strings: strings) + } + + /// What a row says. Static so the rule can be exercised directly: a title that reads the same + /// for two different paths is a bug you can only see by comparing two of them, which a rendered + /// view can't easily be asked about. + static func title(for resolved: ResolvedPath, strings: CustomerCenterStrings) -> String { let path = resolved.path if let title = path.title { return title } switch path.type { @@ -58,7 +65,7 @@ struct PathsListView: View { 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 .url(_, let title, _): return title case .custom(let identifier): return identifier } } diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 9a903718df..fda0195a7c 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -111,7 +111,7 @@ options.customerCenter = CustomerCenterConfiguration( .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: "faq", type: .url(URL(string: "https://mycompany.com/faq")!, title: "FAQ", openMethod: .inApp)), .init(id: "contact_support", type: .contactSupport) ] ), @@ -126,10 +126,15 @@ 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:)`` +plans, and contacting support; ``CustomerCenterConfiguration/PathType/url(_:title:openMethod:)`` +opens a URL either in-app or externally, and ``CustomerCenterConfiguration/PathType/custom(identifier:)`` lets you handle an action entirely yourself via the delegate. +Every path type but `url` names its own row, so `title` on ``CustomerCenterConfiguration/Path`` is +optional and overrides that default. `url` takes a title of its own because there is no sensible +default: a URL could be anything, and its host is the same across all of your own links, so +deriving one would render your FAQ, terms and privacy rows identically. + ### Warning customers about old versions The Customer Center can show a banner asking the customer to update. By default it finds the diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 0e454c1496..d7b39320a8 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 0A1366F15DD3C1761C095DF5 /* SuperwallOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */; }; 0A35D3D8CCC8D043628A0A4F /* TrackingAuthorizationStatusConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC92F1146FA9FA76AF25227 /* TrackingAuthorizationStatusConversionTests.swift */; }; 0A5EFFC920E6BB29814BD66B /* Foundation+ASN1Coder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80281364A23252E30DEEA1AA /* Foundation+ASN1Coder.swift */; }; + 0A6711CD579DFCD3F0E4F263 /* PathTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */; }; 0AB9CCC164DD87C81318AAB0 /* PublicIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */; }; 0B5A0C6EA2D1C98B32110FD9 /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5DCFB58EF4DBC9084A6B89 /* NotificationScheduler.swift */; }; 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F538F901BAF97DDCA86F84 /* DeviceHelperMock.swift */; }; @@ -1209,6 +1210,7 @@ CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedTriggerRuleOccurrence.swift; sourceTree = ""; }; CD8C0C8DA633BE856F5B9EEF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; CD9298A79020030E9A1357A6 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathTitleTests.swift; sourceTree = ""; }; CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallOptions.swift; sourceTree = ""; }; D00CE1D40C874F73A3BEC090 /* PresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationInfo.swift; sourceTree = ""; }; D054D402A5F820BB3D18D8AC /* StoreProductAdapterObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProductAdapterObjc.swift; sourceTree = ""; }; @@ -1402,6 +1404,7 @@ 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */, 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */, + CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */, ); path = Views; sourceTree = ""; @@ -3666,6 +3669,7 @@ F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */, A191F045B8A9EE2D4A3B757D /* OccurrenceLogicTests.swift in Sources */, 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */, + 0A6711CD579DFCD3F0E4F263 /* PathTitleTests.swift in Sources */, 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */, 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */, 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift index 07e7a164c9..6896fe66ed 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/AppStoreUpdateCheckTests.swift @@ -9,7 +9,11 @@ import Testing import Foundation @testable import SuperwallKit -@Suite("App Store update check") +/// `.serialized` because `StubURLProtocol` below is the only way to stand in for the network here, +/// and `URLSession` instantiates protocol classes itself — so its configuration and its record of +/// what was requested have to be static. Run in parallel, the cases overwrite each other's stub +/// responses and share a request count, which is how they passed locally and failed on CI. +@Suite("App Store update check", .serialized) @MainActor struct AppStoreUpdateCheckTests { private func makeViewModel( @@ -292,6 +296,38 @@ struct AppStoreUpdateCheckTests { #expect(StubURLProtocol.requestedURLs.count == 1, "and no second request was made") } + /// The cache holds one answer, and the answer is region-specific: a version that exists in one + /// store may not exist in another. Keying on time alone served the previous region's answer for + /// the rest of the day — precisely when it is most likely to be wrong. + @Test("changing region is a cache miss") + func lookupDoesNotServeAnotherRegionsAnswer() async throws { + StubURLProtocol.reset() + let defaults = try makeDefaults() + let session = makeStubbedSession() + let clock = Date(timeIntervalSince1970: 1_000_000) + + let inGB = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "GB", + defaults: defaults, + session: session, + now: { clock } + ) + #expect(await inGB.latestAppStoreVersion() == "3.2.1") + + StubURLProtocol.body = Data(#"{"results":[{"version":"1.0.0"}]}"#.utf8) + let inJP = AppStoreVersionLookup( + bundleId: "com.acme.app", + regionCode: "JP", + defaults: defaults, + session: session, + now: { clock } + ) + + #expect(await inJP.latestAppStoreVersion() == "1.0.0", "same second, different store") + #expect(StubURLProtocol.requestedURLs.count == 2) + } + @Test("the cache expires after 24 hours") func lookupRefetchesAfterTheDay() async throws { StubURLProtocol.reset() diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift index f5ad4ee01d..53cab19d31 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CustomerCenterPathResolverTests.swift @@ -41,7 +41,7 @@ struct CustomerCenterPathResolverTests { @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: "faq", type: .url(URL(string: "https://a.b")!, title: "FAQ", openMethod: .inApp))) p.append(.init(id: "c", type: .custom(identifier: "x"))) #expect(destinations(context(nil), p) == [.restore, .contactSupport, .url(URL(string: "https://a.b")!, inApp: true), .custom("x")]) } @@ -120,7 +120,7 @@ struct CustomerCenterPathResolverTests { @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))) + var p = paths; p.append(.init(id: "faq", type: .url(URL(string: "https://a.b")!, title: "FAQ", openMethod: .external))) #expect(destinations(context(nil, canOpen: false), p) == [.restore]) } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift index 79058285f7..206e61bf73 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift @@ -14,16 +14,20 @@ import Foundation struct WebSubscriptionPathTests { private let managementURL = URL(string: "https://superwall.app/manage")! - private func webSubscription(store: ProductStore = .stripe) -> SubscriptionTransaction { + private func webSubscription( + store: ProductStore = .stripe, + isActive: Bool = true, + isRevoked: Bool = false + ) -> SubscriptionTransaction { SubscriptionTransaction( transactionId: "web_1", productId: "web_pro_monthly", purchaseDate: Date().addingTimeInterval(-30 * 86_400), willRenew: true, - isRevoked: false, + isRevoked: isRevoked, isInGracePeriod: false, isInBillingRetryPeriod: false, - isActive: true, + isActive: isActive, expirationDate: Date().addingTimeInterval(12 * 86_400), subscriptionGroupId: nil, store: store @@ -33,11 +37,12 @@ struct WebSubscriptionPathTests { private func makeViewModel( store: ProductStore = .stripe, webManagementURL: URL?, - survey: CustomerCenterConfiguration.FeedbackSurvey? = nil + survey: CustomerCenterConfiguration.FeedbackSurvey? = nil, + subscriptions: [SubscriptionTransaction]? = nil ) async -> CustomerCenterViewModel { let (deps, _, _) = CustomerCenterDependencies.mock( info: CustomerInfo( - subscriptions: [webSubscription(store: store)], + subscriptions: subscriptions ?? [webSubscription(store: store)], nonSubscriptions: [], entitlements: [] ), @@ -163,6 +168,54 @@ struct WebSubscriptionPathTests { #expect(manage?.destination == .webManageUnavailable) } + /// The App Store branch gates on the subscription being live; the web branch gated on the store + /// alone, so anything that merely *came from* a web store was offered a management row — a + /// single Stripe charge with no subscription behind it, and a subscription that had already + /// lapsed or been revoked. + @available(iOS 15.0, *) + @Test("nothing left to manage means no management row") + func nothingToManageMeansNoRow() async { + let lapsed = webSubscription(isActive: false, isRevoked: false) + let revoked = webSubscription(isActive: true, isRevoked: true) + + for subscription in [lapsed, revoked] { + let viewModel = await makeViewModel( + webManagementURL: managementURL, + subscriptions: [subscription] + ) + let manage = viewModel.paths(for: viewModel.purchases.first) + .first { $0.path.type == .manageSubscription } + #expect(manage == nil, "a subscription that has ended has nothing to manage") + } + } + + @available(iOS 15.0, *) + @Test("a one-off web purchase has no subscription to manage") + func oneOffWebPurchaseHasNoRow() async { + let purchase = NonSubscriptionTransaction( + transactionId: "web_1", + productId: "web_lifetime", + purchaseDate: Date().addingTimeInterval(-86_400), + isConsumable: false, + isRevoked: false, + store: .stripe + ) + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: [], nonSubscriptions: [purchase], entitlements: []), + environment: EnvironmentMock(webManagementURL: managementURL) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + + let manage = viewModel.paths(for: viewModel.purchases.first) + .first { $0.path.type == .manageSubscription } + #expect(manage == nil, "there is no subscription behind a one-time charge") + } + // MARK: - Surveys don't belong on a web flow /// The survey gates an action. On a web flow that action leaves the app — or, with no URL, can't diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift index 273f8ebc14..eaf448030e 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterActionTests.swift @@ -12,7 +12,7 @@ struct CustomerCenterActionTests { #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: .url(url, title: "Help", openMethod: .external)) == .url(url)) #expect(CustomerCenterAction(pathType: .custom(identifier: "x")) == .custom(identifier: "x")) } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift index b88e39be70..0579f7fca7 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift @@ -32,7 +32,7 @@ struct CustomerCenterConfigurationTests { config.support.latestAppVersion = "2.1.0" config.support.webManagementURL = URL(string: "https://app.superwall.app/manage") config.appearance.accent = .init(light: "#112233", dark: "#AABBCC") - config.managementScreen.paths.append(.init(id: "faq", type: .url(URL(string: "https://app.com/faq")!, openMethod: .inApp), title: "FAQ")) + config.managementScreen.paths.append(.init(id: "faq", type: .url(URL(string: "https://app.com/faq")!, title: "FAQ", openMethod: .inApp))) config.managementScreen.paths.append(.init(id: "del", type: .custom(identifier: "delete_account"))) config.managementScreen.paths.append(.init(id: "ref", type: .refund(window: 3600))) config.managementScreen.paths.append(.init(id: "chg", type: .changePlan(productIds: ["a", "b"]))) diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift index ed45ad2001..7497f575aa 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift @@ -224,8 +224,8 @@ struct CustomerCenterViewModelTests { 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: "ext", type: .url(ext, title: "External", openMethod: .external)), + .init(id: "in", type: .url(inApp, title: "In app", openMethod: .inApp)), .init(id: "c", type: .custom(identifier: "delete")) ] let (vm, _, _) = make(info: info([sub()]), config: config, opener: opener) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift new file mode 100644 index 0000000000..cde6c86112 --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/PathTitleTests.swift @@ -0,0 +1,74 @@ +// +// PathTitleTests.swift +// +// +// Created by Jordan Morgan on 10/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +@Suite("Path row titles") +struct PathTitleTests { + @available(iOS 15.0, *) + private func title( + _ type: CustomerCenterConfiguration.PathType, + pathTitle: String? = nil, + destination: ResolvedPathDestination = .restore + ) -> String { + let path = CustomerCenterConfiguration.Path(id: "p", type: type, title: pathTitle) + return PathsListView.title( + for: ResolvedPath(path: path, destination: destination), + strings: .english + ) + } + + /// The bug this rule exists to prevent. The title used to be derived from the URL's host, which + /// is identical across an app's own links — so three distinct destinations rendered as three + /// identical rows and the customer had no way to tell them apart. + @available(iOS 15.0, *) + @Test("URL rows on one host still read differently") + func urlRowsOnTheSameHostAreDistinct() { + let rows = [ + title(.url(URL(string: "https://acme.com/faq")!, title: "FAQ", openMethod: .inApp)), + title(.url(URL(string: "https://acme.com/terms")!, title: "Terms of Service", openMethod: .inApp)), + title(.url(URL(string: "https://acme.com/privacy")!, title: "Privacy Policy", openMethod: .external)) + ] + + #expect(rows == ["FAQ", "Terms of Service", "Privacy Policy"]) + #expect(Set(rows).count == 3, "a shared host must not collapse three rows into one label") + } + + /// Every other type names itself, so a title is optional there and overrides the default. + @available(iOS 15.0, *) + @Test("built-in types fall back to their localized label") + func builtInTypesNameThemselves() { + #expect(title(.restore) == CustomerCenterStrings.english.string("customer_center_path_restore")) + #expect(title(.refund()) == CustomerCenterStrings.english.string("customer_center_path_refund")) + #expect(title(.contactSupport) == CustomerCenterStrings.english.string("customer_center_path_contact_support")) + } + + @available(iOS 15.0, *) + @Test("an explicit title wins over every default", arguments: [ + CustomerCenterConfiguration.PathType.restore, + .contactSupport, + .url(URL(string: "https://acme.com/faq")!, title: "FAQ", openMethod: .inApp) + ]) + func explicitTitleWins(type: CustomerCenterConfiguration.PathType) { + #expect(title(type, pathTitle: "Help me") == "Help me") + } + + /// The manage row is the one built-in whose label depends on where it leads: a web management + /// page does more than cancel, so calling it "Cancel subscription" there undersells it. + @available(iOS 15.0, *) + @Test("the manage row is named for the store it leads to") + func manageRowNamedForItsDestination() { + let appStore = title(.manageSubscription, destination: .appleManageSheet(subscriptionGroupId: nil)) + let web = title(.manageSubscription, destination: .webManageUnavailable) + + #expect(appStore == CustomerCenterStrings.english.string("customer_center_path_manage_subscription")) + #expect(web == CustomerCenterStrings.english.string("customer_center_path_manage_subscription_web")) + #expect(appStore != web) + } +} From d0be8f83df83e74d75d8aa841b42ddb8907700dd Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 11 Sep 2026 12:05:47 -0500 Subject: [PATCH 59/64] feat(customer-center): a card needs a name, and every subscription opens its own screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces of design-review feedback. **A purchase with no display name is not shown.** A card is titled with its product's name, and a card without one reads as a raw identifier — `test:price_1TuEiI4PyZVB2o4B7dU1tBMh:no-trial`, wrapping to two lines — which is worse than no card. `ProductDisplayInfo` now records whether it found a name (StoreKit's `displayName`, or the catalogue's `name`), and the builder drops subscriptions and one-off purchases whose product resolved without one. Two deliberate edges. A product that didn't resolve at all keeps the identifier fallback: that is a lookup failure, not a naming decision, and turning every StoreKit hiccup into a vanished subscription would be the wrong trade. And entitlement-only rows are outside the rule — they have no product to be named by, and the entitlement's own identifier is what they show. Today this hides every web purchase, because `/v1/products` carries no `name` yet. The SDK already reads the field; they appear the moment the backend sends it. **Purchase history is gone, and every subscription row opens its detail screen.** The single-subscription layout used to inline that subscription's actions on the root; now one subscription and several are laid out the same way, and the root keeps only the actions that apply to the account. `showsPurchaseHistory`, `PurchaseHistoryView`, `PurchaseDetailRows`, `historySections()` and their five strings across 41 localizations are removed; the inline cap on one-off purchases goes with them, since there is no longer a second screen to reach the rest. **Why the Stripe card showed an identifier** was a finding rather than a fix: the title chain is StoreKit `displayName` → catalogue `name` → product id, the catalogue sends no name, and the product's entitlement — reachable via `entitlementsByProductId` — is never consulted for a title. With the rule above, that path now yields no card instead of a bad one. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +- .../Logic/PurchasePresentationBuilder.swift | 24 ++++- .../Models/CustomerCenterConfiguration.swift | 6 -- .../Models/PurchasePresentation.swift | 3 + .../CustomerCenterDependencies.swift | 13 +-- .../ViewModel/CustomerCenterViewModel.swift | 11 --- .../Views/CustomerCenterStrings+English.swift | 5 - .../Views/ManagementScreenView.swift | 41 ++------ .../Views/PurchaseHistoryView.swift | 97 ------------------- .../Documentation.docc/CustomerCenter.md | 10 +- .../ar.lproj/Localizable.strings | 5 - .../ca.lproj/Localizable.strings | 5 - .../cs.lproj/Localizable.strings | 5 - .../da.lproj/Localizable.strings | 5 - .../de.lproj/Localizable.strings | 5 - .../el.lproj/Localizable.strings | 5 - .../en.lproj/Localizable.strings | 5 - .../en_AU.lproj/Localizable.strings | 5 - .../en_GB.lproj/Localizable.strings | 5 - .../es.lproj/Localizable.strings | 5 - .../es_419.lproj/Localizable.strings | 5 - .../fi.lproj/Localizable.strings | 5 - .../fr.lproj/Localizable.strings | 5 - .../fr_CA.lproj/Localizable.strings | 5 - .../he.lproj/Localizable.strings | 5 - .../hi.lproj/Localizable.strings | 5 - .../hr.lproj/Localizable.strings | 5 - .../hu.lproj/Localizable.strings | 5 - .../id.lproj/Localizable.strings | 5 - .../it.lproj/Localizable.strings | 5 - .../ja.lproj/Localizable.strings | 5 - .../ko.lproj/Localizable.strings | 5 - .../ms.lproj/Localizable.strings | 5 - .../nb.lproj/Localizable.strings | 5 - .../nl.lproj/Localizable.strings | 5 - .../nn.lproj/Localizable.strings | 5 - .../pl.lproj/Localizable.strings | 5 - .../pt.lproj/Localizable.strings | 5 - .../pt_BR.lproj/Localizable.strings | 5 - .../pt_PT.lproj/Localizable.strings | 5 - .../ro.lproj/Localizable.strings | 5 - .../ru.lproj/Localizable.strings | 5 - .../sk.lproj/Localizable.strings | 5 - .../sl.lproj/Localizable.strings | 5 - .../sv.lproj/Localizable.strings | 5 - .../th.lproj/Localizable.strings | 5 - .../tr.lproj/Localizable.strings | 5 - .../uk.lproj/Localizable.strings | 5 - .../vi.lproj/Localizable.strings | 5 - .../zh_Hans.lproj/Localizable.strings | 5 - .../zh_Hant.lproj/Localizable.strings | 5 - SuperwallKit.xcodeproj/project.pbxproj | 8 -- .../PurchasePresentationBuilderTests.swift | 54 +++++++++++ .../Logic/WebProductPricingTests.swift | 58 +++++++---- .../CustomerCenterConfigurationTests.swift | 2 +- .../Views/CustomerCenterViewSmokeTests.swift | 3 +- .../Views/DesignReviewSnapshots.swift | 24 ++--- .../Views/ManagementScreenViewTests.swift | 77 --------------- 58 files changed, 155 insertions(+), 490 deletions(-) delete mode 100644 Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift delete mode 100644 Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ae41f7cf..e7ccbc0e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### 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`), including an accent colour for light and dark. Requires iOS 15+. +- Adds the Customer Center: a native, self-service screen where users can view their subscriptions and purchases, restore purchases, manage or cancel a subscription, request a refund, change plans, contact support, answer exit surveys and open any subscription for its details. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`), including an accent colour for light and dark. Requires iOS 15+. - Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. - The Customer Center's update banner now finds the published version itself, by looking the app up on the App Store, so `latestAppVersion` no longer has to be kept current by hand. Set `SuperwallOptions.customerCenter.support.checksAppStoreForUpdates = false` to opt out, or keep setting `latestAppVersion` — a configured version always wins and skips the lookup. The check is skipped on TestFlight, sandbox and simulator builds, whose version is normally ahead of the App Store. Note that Apple phases releases in over seven days while the lookup sees a new version immediately, so early in a release some customers may be prompted to update before the build reaches them. -- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. 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. +- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. A web purchase whose product has no display name in the catalogue isn't shown at all, rather than being titled with its identifier; the SDK picks the name up as soon as the catalogue returns one. - Improved the Customer Center for subscriptions bought on the web (Stripe, Paddle). The management row is now labelled "Manage subscription" rather than "Cancel subscription", since a web management page does more than cancel; it stays visible when no management URL is configured, explaining that the link is in the customer's emailed receipt, instead of disappearing and leaving them with no action at all; and feedback surveys are skipped for web flows, which hand off to a browser rather than completing in the app. - `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it renders into your navigation bar without modifying it, and its own screens are pushed onto your stack as further view controllers. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift index fa357b16bd..4f0a268229 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -44,6 +44,21 @@ struct PurchasePresentationBuilder { return subs + nonSubs + entitlementOnly } + /// Whether a purchase backed by `product` is shown at all. + /// + /// A card is titled with its product's display name, and a card without one reads as a raw + /// identifier — `test:price_1Tu…:no-trial` — which is worse than no card. So a product that + /// resolved *without* a name hides its purchase. A product that didn't resolve at all is left + /// alone: that is a lookup failure, not a naming decision, and turning every StoreKit hiccup + /// into a vanished subscription would be the wrong trade — the identifier fallback stays for it. + /// + /// Entitlement-only rows are outside this rule. They have no product to be named by, and the + /// entitlement's own identifier is what they show. + static func isNameable(_ product: ProductDisplayInfo?) -> Bool { + guard let product else { return true } + return product.hasDisplayName + } + func subscriptionPresentations( _ subscriptions: [SubscriptionTransaction], products: [String: ProductDisplayInfo] @@ -70,7 +85,11 @@ struct PurchasePresentationBuilder { case (nil, nil): return lhs.purchaseDate < rhs.purchaseDate } } - return sorted.map { presentation(for: $0, product: products[$0.productId]) } + return sorted.compactMap { sub in + let product = products[sub.productId] + guard Self.isNameable(product) else { return nil } + return presentation(for: sub, product: product) + } } /// Whether `lhs` better represents its product than `rhs` when both are transactions of the @@ -90,8 +109,9 @@ struct PurchasePresentationBuilder { _ purchases: [NonSubscriptionTransaction], products: [String: ProductDisplayInfo] ) -> [PurchasePresentation] { - purchases.sorted { $0.purchaseDate < $1.purchaseDate }.map { purchase in + purchases.sorted { $0.purchaseDate < $1.purchaseDate }.compactMap { purchase -> PurchasePresentation? in let product = products[purchase.productId] + guard Self.isNameable(product) else { return nil } // Keyed by transaction id, not product id: consumables can legitimately be purchased // multiple times, and each purchase gets its own row. return PurchasePresentation( diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift index dc88401a21..470c37887e 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift @@ -23,8 +23,6 @@ public final class CustomerCenterConfiguration: NSObject, Codable { 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`. @@ -35,7 +33,6 @@ public final class CustomerCenterConfiguration: NSObject, Codable { noPurchasesScreen: Screen, support: Support = Support(), appearance: Appearance = Appearance(), - showsPurchaseHistory: Bool = true, showsAccountDetails: Bool = true, warnsAboutDuplicateSubscriptions: Bool = true ) { @@ -43,7 +40,6 @@ public final class CustomerCenterConfiguration: NSObject, Codable { self.noPurchasesScreen = noPurchasesScreen self.support = support self.appearance = appearance - self.showsPurchaseHistory = showsPurchaseHistory self.showsAccountDetails = showsAccountDetails self.warnsAboutDuplicateSubscriptions = warnsAboutDuplicateSubscriptions } @@ -87,7 +83,6 @@ public final class CustomerCenterConfiguration: NSObject, Codable { && noPurchasesScreen == other.noPurchasesScreen && support == other.support && appearance == other.appearance - && showsPurchaseHistory == other.showsPurchaseHistory && showsAccountDetails == other.showsAccountDetails && warnsAboutDuplicateSubscriptions == other.warnsAboutDuplicateSubscriptions } @@ -98,7 +93,6 @@ public final class CustomerCenterConfiguration: NSObject, Codable { hasher.combine(noPurchasesScreen) hasher.combine(support) hasher.combine(appearance) - hasher.combine(showsPurchaseHistory) hasher.combine(showsAccountDetails) hasher.combine(warnsAboutDuplicateSubscriptions) return hasher.finalize() diff --git a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift index 68522b305d..0b9371e863 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift @@ -16,6 +16,9 @@ struct ProductDisplayInfo: Equatable { var localizedPeriod: String? var subscriptionGroupId: String? var isAutoRenewable: Bool? + /// Whether `title` is a real display name rather than the product identifier standing in for + /// one. A purchase is only shown when this is `true` — see `PurchasePresentationBuilder`. + var hasDisplayName = true } enum PurchaseBadge: Equatable { diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 8916406ac4..43dbeb9279 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -78,14 +78,14 @@ extension ProductDisplayInfo { /// - Parameter name: A display name from outside StoreKit — the Superwall catalogue, for a web /// product StoreKit can't resolve. Ignored when `nil` or empty, leaving the usual fallbacks. init(_ product: StoreProduct, name: String? = nil) { - var title = product.productIdentifier + var displayName: String? if #available(iOS 15.0, *), let name = product.sk2Product?.displayName, !name.isEmpty { - title = name + displayName = name } else if let name = product.sk1Product?.localizedTitle, !name.isEmpty { - title = name + displayName = name } if let name, !name.isEmpty { - title = name + displayName = name } var isAutoRenewable: Bool? if #available(iOS 15.0, *), let type = product.sk2Product?.type { @@ -93,12 +93,13 @@ extension ProductDisplayInfo { } self.init( productId: product.productIdentifier, - title: title, + title: displayName ?? product.productIdentifier, localizedPrice: product.localizedPrice, price: product.price, localizedPeriod: product.subscriptionPeriod == nil ? nil : product.period, subscriptionGroupId: product.subscriptionGroupIdentifier, - isAutoRenewable: isAutoRenewable + isAutoRenewable: isAutoRenewable, + hasDisplayName: displayName != nil ) } } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 47aeb24d28..119ed55afb 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -318,17 +318,6 @@ final class CustomerCenterViewModel: ObservableObject { } await apply(customerInfo: info, refetchProducts: true) } - - - // 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 }) - } } // MARK: - Visibility-driven dismissal diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 7a19897d40..b8af7d8515 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -91,11 +91,6 @@ let englishStrings: [String: String] = [ "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", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index bcab7ec297..8f47cfa6d1 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -15,7 +15,6 @@ struct ManagementScreenView: View { 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 { @@ -26,37 +25,26 @@ struct ManagementScreenView: View { DuplicateSubscriptionBanner() } if !subscriptions.isEmpty { + // Every subscription is a row that opens its own detail screen, one or many alike. This + // screen keeps the actions that apply to the account; anything that only makes sense + // against one subscription — change plan, refund, cancel — lives where the row leads. Section(strings.string("customer_center_section_subscriptions")) { ForEach(subscriptions) { purchase in - if isSingle { + CustomerCenterDrillDown { + PurchaseDetailScreenView(viewModel: viewModel, purchase: purchase) + } label: { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) - } else { - CustomerCenterDrillDown { - PurchaseDetailScreenView(viewModel: viewModel, purchase: purchase) - } label: { - PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) - } } } } } if !others.isEmpty { Section(strings.string("customer_center_section_purchases")) { - ForEach(visibleOthers) { PurchaseCardView(purchase: $0, refundResult: nil) } + ForEach(others) { 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 { - CustomerCenterDrillDown { - PurchaseHistoryView(viewModel: viewModel) - } label: { - Text(strings.string("customer_center_see_all_purchases")) - } - .accessibilityIdentifier("customer_center.purchase_history") - } + PathsListView(viewModel: viewModel, purchase: nil) } if viewModel.configuration.showsAccountDetails { AccountDetailsSection(viewModel: viewModel) @@ -70,22 +58,13 @@ 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") } } -/// Detail for one purchase when the user has several. +/// Detail for one subscription, reached by tapping its row on the management screen. Carries the +/// actions that only make sense against that subscription. @available(iOS 15.0, *) struct PurchaseDetailScreenView: View { @ObservedObject var viewModel: CustomerCenterViewModel diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift deleted file mode 100644 index 9539faeef8..0000000000 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseHistoryView.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// PurchaseHistoryView.swift -// -// -// Created by Jordan Morgan 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) - .onAppear { viewModel.surfaceDidAppear() } - .onDisappear { viewModel.surfaceDidDisappear() } - } - - @ViewBuilder - private func historySection(_ key: String, _ items: [PurchasePresentation]) -> some View { - if !items.isEmpty { - Section(strings.string(key)) { - ForEach(items) { item in - CustomerCenterDrillDown { - PurchaseDetailRows(viewModel: viewModel, purchase: item) - } label: { - PurchaseCardView(purchase: item, refundResult: nil) - } - } - } - } - } -} - -@available(iOS 15.0, *) -struct PurchaseDetailRows: View { - @ObservedObject var viewModel: CustomerCenterViewModel - let purchase: PurchasePresentation - @Environment(\.customerCenterStrings) private var strings - 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 - formatter.locale = viewModel.locale - dateFormatter = 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(strings.string("customer_center_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) - .onAppear { viewModel.surfaceDidAppear() } - .onDisappear { viewModel.surfaceDidDisappear() } - } - - private func row(_ label: String, _ value: String) -> some View { - HStack { Text(label); Spacer(); Text(value).foregroundStyle(.secondary).textSelection(.enabled) } - } -} diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index fda0195a7c..50dbfbab0e 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -5,7 +5,7 @@ A native, self-service screen where users can view and manage their subscription ## 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 +refund, change plans, contact support, answer exit surveys and open any subscription for its details — all without leaving your app. It ships with sensible defaults and is fully configurable, so you can tailor which paths appear, their titles, surveys and accent colour to match your app. @@ -39,9 +39,8 @@ navigationController?.pushViewController(customerCenter, animated: true) A pushed Customer Center renders into your navigation bar and leaves it alone — your title, your back button, your appearance, your swipe-to-go-back. It adds no close button, since your stack -already provides the way back. Its own screens, purchase history and per-purchase detail, are -pushed onto your stack as further view controllers, so they behave like any other screen you -pushed yourself. +already provides the way back. Its own screen — the detail for a subscription — is pushed onto +your stack as a further view controller, so it behaves like any other screen you pushed yourself. > Important: A `CustomerCenterViewController` you construct yourself is yours, and the SDK does not > track it. ``Superwall/presentCustomerCenter(configuration:from:delegate:onDismiss:)`` will present @@ -230,6 +229,9 @@ The Customer Center fires the following ``SuperwallEvent`` cases, which you can ## Limitations - Requires iOS 15.0+. On earlier versions, presentation calls are unavailable at compile time. +- A purchase whose product has no display name isn't shown. Today that is every web (Stripe, + Paddle) purchase, because the product catalogue doesn't return a name yet; they appear as soon + as it does. App Store purchases always have one. - 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/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index c9b5486af8..6482a40095 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "نسخ"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index fb41eac7b4..3d571fd973 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index 0e83549417..966b2b4140 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 83697b6a03..579bdff0d0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 0c64a0463d..6c52d5e50e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index 5e55e8c42f..9a571babdd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "Αντιγραφή"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 636673eac4..40256c0535 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 636673eac4..40256c0535 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 636673eac4..40256c0535 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index 9337311b7c..9392ff2efb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 7e5b43e6e0..2ab100a551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 036936a3e2..7f16b697fe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index bea606aedd..779c174d3d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index de17fabce0..ae5e9bf282 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 86418594b8..cb7eccf646 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "העתקה"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index d4dce01f39..719be2893d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "कॉपी करें"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 5d50b68d1b..a443360019 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index 226e56018c..29496b1ba6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index af9f3626c5..b7ee50193b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 2d33e2e71e..1371745ab3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 25a94403de..3f7223a089 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "コピー"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 9129a575e8..e005378d72 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "복사"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index 22aca04d32..597f7311c6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index 0bf9402ede..40ce23a6d4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index a28aeacacd..798a23dfc0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 0c92ca3995..67f783db70 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 603b397fad..ff6941d794 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index 926b9ead14..836ba47690 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index cc4db1bbe2..d312e48612 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index 7c2947ba60..7db3ee8d15 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 4b762bee3f..8e5a910b43 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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ă"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index d44c3df0a0..019bc9959c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "Копировать"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index 1b773a07d1..4547746aa2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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ť"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 0086c3bdaf..d3b08fd3de 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index b8ea720d86..7b3c6ced7e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 48037aa718..cfd18c5deb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "คัดลอก"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 3a795647f5..3e30590b6f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index cde053d23f..672cb74b37 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "Копіювати"; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index aad840ce47..bc91d0a2b2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 0a140c1473..cd904b93c0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "复制"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index b483200aee..13389e4f7e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -96,11 +96,6 @@ "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" = "複製"; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index d7b39320a8..3851e2d89e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -43,7 +43,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 */; }; - 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 */; }; @@ -559,7 +558,6 @@ 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 */; }; E38FE7475E82AC6EA10F2C65 /* CustomerCenterViewModel+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */; }; @@ -969,7 +967,6 @@ 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 = ""; }; 7FE43B98D847BB6DE291F0B4 /* FakeTrackingAuthorizationStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeTrackingAuthorizationStatusTests.swift; sourceTree = ""; }; 8012E350CCE22B0D892E0F96 /* PaywallManagerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogic.swift; sourceTree = ""; }; @@ -1267,7 +1264,6 @@ 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 = ""; }; @@ -1403,7 +1399,6 @@ 2AA2DEE352680123DB866281 /* CustomerCenterSheetOwnershipTests.swift */, 2C865FA4B20684772E0E3328 /* CustomerCenterViewSmokeTests.swift */, 09922D2B36823996D4982234 /* DesignReviewSnapshots.swift */, - 7EBEC3638B8BD423E4951BEE /* ManagementScreenViewTests.swift */, CFA6EFE01A40D0F00CEF6085 /* PathTitleTests.swift */, ); path = Views; @@ -1507,7 +1502,6 @@ B95FB737271FE6289A0A4BD1 /* NoPurchasesScreenView.swift */, 9842687E40C9BBAE8EE5A126 /* PathsListView.swift */, A3E4A9BDC6252EE01D88197D /* PurchaseCardView.swift */, - E705F9954F808C341A4D0EBD /* PurchaseHistoryView.swift */, 9C515E4FDA405CBFADFA9FAB /* RestoreOverlay.swift */, ); path = Views; @@ -3653,7 +3647,6 @@ 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 */, @@ -4041,7 +4034,6 @@ 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 */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index 3624dc84e3..e3e414a29f 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -227,4 +227,58 @@ struct PurchasePresentationBuilderTests { #expect(Set(rows.map(\.id)).count == 2) #expect(rows.allSatisfy { $0.productId == "coins" }) } + + // MARK: - A card needs a name + + /// What a Stripe purchase looks like today: the catalogue resolved the product and priced it, + /// but sent no display name. The alternative to hiding it is a card titled + /// `test:price_1Tu…:no-trial`, which is the thing this rule exists to prevent. + @Test("a subscription whose product resolved without a display name is not shown") + func unnamedProductHidesTheSubscription() { + var unnamed = monthly + unnamed.title = "monthly" + unnamed.hasDisplayName = false + let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: ["monthly": unnamed]) + #expect(rows.isEmpty) + } + + @Test("a one-off purchase whose product resolved without a display name is not shown") + func unnamedProductHidesTheOneOff() { + let coins = NonSubscriptionTransaction( + transactionId: "n", + productId: "coins", + purchaseDate: now, + isConsumable: true, + isRevoked: false, + store: .stripe + ) + let unnamed = ProductDisplayInfo( + productId: "coins", + title: "coins", + localizedPrice: "$0.99", + price: 0.99, + localizedPeriod: nil, + subscriptionGroupId: nil, + isAutoRenewable: false, + hasDisplayName: false + ) + let rows = builder.build(customerInfo: info(nonSubs: [coins]), products: ["coins": unnamed]) + #expect(rows.isEmpty) + } + + /// The rule is per card. One unnamed product must not take a named one down with it — and + /// `missingProduct` above pins the other edge: a product that didn't resolve at all keeps its + /// identifier, because that's a lookup failure rather than a naming decision. + @Test("hiding is per card, not all-or-nothing") + func hidingIsPerCard() { + var unnamed = monthly + unnamed.productId = "web" + unnamed.title = "web" + unnamed.hasDisplayName = false + let rows = builder.build( + customerInfo: info(subs: [sub("monthly"), sub("web", store: .stripe)]), + products: ["monthly": monthly, "web": unnamed] + ) + #expect(rows.map(\.id) == ["monthly"]) + } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift index cb67023b30..36b2534c7f 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -56,17 +56,51 @@ struct WebProductPricingTests { #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 { + /// A catalogue product with no display name yields no card. The price is there — that's what + /// the catalogue is for — but a card is titled with its product's name, and a card titled with + /// `test:price_…:no-trial` is worse than no card. The name is the backend's to send. + @Test("a web product with no display name is not shown", arguments: [199, 999, 7999]) + func unnamedWebProductIsHidden(amountInCents: Int) throws { let product = try decodeProduct(amountInCents: amountInCents) let storeProduct = StoreProduct( catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) ) let display = ProductDisplayInfo(storeProduct) + #expect(display.localizedPrice?.contains(".") == true, "the price itself resolved") + #expect(!display.hasDisplayName) + + let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) + let presentations = builder.build( + customerInfo: CustomerInfo(subscriptions: [webSubscription()], nonSubscriptions: [], entitlements: []), + products: ["web_pro_monthly": display] + ) + #expect(presentations.isEmpty, "no name, no card") + } + + /// The same purchase once the catalogue names it: the card appears, titled by the catalogue, + /// with the price on it. + @Test("the card shows a price and the catalogue's name once there is one", arguments: [199, 999, 7999]) + func cardShowsPrice(amountInCents: Int) throws { + let product = try decodeProduct(amountInCents: amountInCents, name: "Pro") + let storeProduct = StoreProduct( + catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) + ) + let display = ProductDisplayInfo(storeProduct, name: product.name) + + let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) + let presentations = builder.build( + customerInfo: CustomerInfo(subscriptions: [webSubscription()], nonSubscriptions: [], entitlements: []), + products: ["web_pro_monthly": display] + ) + let card = try #require(presentations.first) + + #expect(card.priceLine != nil) + #expect(card.statusLine.contains(display.localizedPrice ?? "!"), "the renewal line quotes the price") + #expect(card.title == "Pro") + } - let subscription = SubscriptionTransaction( + private func webSubscription() -> SubscriptionTransaction { + SubscriptionTransaction( transactionId: "web_1", productId: "web_pro_monthly", purchaseDate: Date().addingTimeInterval(-30 * 86_400), @@ -79,20 +113,6 @@ struct WebProductPricingTests { 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") - // 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. Pins that `name` decodes off the payload and that diff --git a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift index 0579f7fca7..b60a5f8c63 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Models/CustomerCenterConfigurationTests.swift @@ -15,7 +15,7 @@ struct CustomerCenterConfigurationTests { #expect(manage.survey?.options.map(\.id) == ["too_expensive", "dont_use", "bought_by_mistake"]) #expect(config.support.email == nil) #expect(config.support.shouldWarnToUpdate == true) - #expect(config.showsPurchaseHistory && config.showsAccountDetails && config.warnsAboutDuplicateSubscriptions) + #expect(config.showsAccountDetails && config.warnsAboutDuplicateSubscriptions) } @Test("default returns a fresh instance each time") diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift index 27ae224804..653e88d2fc 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/CustomerCenterViewSmokeTests.swift @@ -77,7 +77,8 @@ struct CustomerCenterViewSmokeTests { 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 detail = PurchaseDetailScreenView(viewModel: vm, purchase: purchase) + for view in [AnyView(FeedbackSurveyView(viewModel: vm)), AnyView(detail)] { let host = UIHostingController(rootView: view) host.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) let window = UIWindow(frame: host.view.frame) diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift index a836134fe1..e94deec1b7 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -140,11 +140,12 @@ struct DesignReviewSnapshots { subscriptionGroupId: nil, isAutoRenewable: false ), - // A web product as it actually arrives: the catalogue supplies its price, but `/v1/products` - // carries no display name, so the card is headed with the raw identifier. + // A web product as it will arrive once `/v1/products` carries a display name. Until it does, + // a web purchase has no name and therefore no card — see `PurchasePresentationBuilder` — so + // the unnamed shape isn't worth a screenshot: it renders as an empty management screen. "web_pro_monthly": .init( productId: "web_pro_monthly", - title: "web_pro_monthly", + title: "Pro", localizedPrice: "$12.99", price: 12.99, localizedPeriod: "month", @@ -336,8 +337,8 @@ struct DesignReviewSnapshots { // 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. + // 2. One active auto-renewing subscription. Its row opens the detail screen, which is where + // the subscription's own actions live. capture( "02-active-subscription", directory: directory, @@ -435,8 +436,7 @@ struct DesignReviewSnapshots { 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. + // 12. A subscription alongside several one-off purchases, every one of them shown. let manyPurchases = await makeViewModel( subscriptions: [subscription()], nonSubscriptions: [ @@ -446,12 +446,7 @@ struct DesignReviewSnapshots { 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) - } + capture("12-many-purchases", directory: directory, viewModel: manyPurchases) // 14. The per-purchase detail screen, reached from the multi-subscription list. let multi = await makeViewModel( @@ -537,9 +532,8 @@ struct DesignReviewSnapshots { ) ) - // 19. History and account details switched off — the most stripped-back screen. + // 19. Account details switched off — the most stripped-back screen. let minimal = defaultConfiguration() - minimal.showsPurchaseHistory = false minimal.showsAccountDetails = false capture( "19-minimal-configuration", diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift deleted file mode 100644 index 3f20318aad..0000000000 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/ManagementScreenViewTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// 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: Fri, 11 Sep 2026 12:17:03 -0500 Subject: [PATCH 60/64] fix(customer-center): a purchase with no name loses its title, never its card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit d0be8f8 read "if no name, hide the entitlement" as hiding the purchase. It meant the label. A paying customer's subscription is never withheld from the screen that exists to manage it; what's withheld is a title made from a raw identifier. So `title` is now optional on both `ProductDisplayInfo` and `PurchasePresentation`, and the fallback chain is: the product's display name (StoreKit's `displayName`, or the catalogue's `name`), else the entitlement the purchase unlocks — reachable through `entitlementsByProductId`, which is what the Stripe card was missing — else nothing. The product identifier never appears. A product can grant several entitlements; the lowest identifier is taken so the title is stable between renders. The card lays its badge beside the text column rather than on a row of its own, so a titleless card doesn't open with an empty line. The detail screen's bar shows no title in the same case. The hiding rule, its `hasDisplayName` flag and the tests that pinned it are gone; the tests now pin the fallback chain and that the card survives every step of it. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- .../Logic/PurchasePresentationBuilder.swift | 58 ++++++++------- .../Models/PurchasePresentation.swift | 11 +-- .../CustomerCenterDependencies.swift | 5 +- .../Views/ManagementScreenView.swift | 2 +- .../Views/PurchaseCardView.swift | 35 ++++----- .../Documentation.docc/CustomerCenter.md | 7 +- .../PurchasePresentationBuilderTests.swift | 72 +++++++++++-------- .../Logic/WebProductPricingTests.swift | 18 ++--- .../CustomerCenterDependenciesTests.swift | 6 +- .../Views/DesignReviewSnapshots.swift | 8 +-- 11 files changed, 127 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ccbc0e95..bff62a244e 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 open any subscription for its details. Present it with `Superwall.shared.presentCustomerCenter()`, embed `CustomerCenterView` in SwiftUI, or use `CustomerCenterViewController` in UIKit. Configure it via `SuperwallOptions.customerCenter` (`CustomerCenterConfiguration`), including an accent colour for light and dark. Requires iOS 15+. - Adds `CustomerCenterDelegate` callbacks and the `customerCenterOpen`, `customerCenterClose`, `customerCenterAction`, `customerCenterSurveyResponse` and `customerCenterRefundRequest` events. - The Customer Center's update banner now finds the published version itself, by looking the app up on the App Store, so `latestAppVersion` no longer has to be kept current by hand. Set `SuperwallOptions.customerCenter.support.checksAppStoreForUpdates = false` to opt out, or keep setting `latestAppVersion` — a configured version always wins and skips the lookup. The check is skipped on TestFlight, sandbox and simulator builds, whose version is normally ahead of the App Store. Note that Apple phases releases in over seven days while the lookup sees a new version immediately, so early in a release some customers may be prompted to update before the build reaches them. -- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. A web purchase whose product has no display name in the catalogue isn't shown at all, rather than being titled with its identifier; the SDK picks the name up as soon as the catalogue returns one. +- The Customer Center now shows prices for subscriptions bought on the web (Stripe, Paddle). StoreKit can't resolve a web product, so those cards previously rendered with no price; the price is now read from the Superwall product catalogue when StoreKit returns nothing. A web purchase whose product has no display name in the catalogue is headed by the entitlement it unlocks instead — never by its raw identifier — and picks the name up as soon as the catalogue returns one. - Improved the Customer Center for subscriptions bought on the web (Stripe, Paddle). The management row is now labelled "Manage subscription" rather than "Cancel subscription", since a web management page does more than cancel; it stays visible when no management URL is configured, explaining that the link is in the customer's emailed receipt, instead of disappearing and leaving them with no action at all; and feedback surveys are skipped for web flows, which hand off to a browser rather than completing in the app. - `CustomerCenterViewController` can be pushed onto a navigation controller of your own as well as presented modally. Pass `presentationStyle: .pushed` to push it: it renders into your navigation bar without modifying it, and its own screens are pushed onto your stack as further view controllers. diff --git a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift index 4f0a268229..3ca923488c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift +++ b/Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift @@ -33,8 +33,17 @@ struct PurchasePresentationBuilder { } func build(customerInfo: CustomerInfo, products: [String: ProductDisplayInfo]) -> [PurchasePresentation] { - let subs = subscriptionPresentations(customerInfo.subscriptions, products: products) - let nonSubs = nonSubscriptionPresentations(customerInfo.nonSubscriptions, products: products) + let entitlementsByProductId = customerInfo.entitlementsByProductId + let subs = subscriptionPresentations( + customerInfo.subscriptions, + products: products, + entitlementsByProductId: entitlementsByProductId + ) + let nonSubs = nonSubscriptionPresentations( + customerInfo.nonSubscriptions, + products: products, + entitlementsByProductId: entitlementsByProductId + ) let knownProductIds = Set( customerInfo.subscriptions.map(\.productId) + customerInfo.nonSubscriptions.map(\.productId) ) @@ -44,24 +53,21 @@ struct PurchasePresentationBuilder { return subs + nonSubs + entitlementOnly } - /// Whether a purchase backed by `product` is shown at all. - /// - /// A card is titled with its product's display name, and a card without one reads as a raw - /// identifier — `test:price_1Tu…:no-trial` — which is worse than no card. So a product that - /// resolved *without* a name hides its purchase. A product that didn't resolve at all is left - /// alone: that is a lookup failure, not a naming decision, and turning every StoreKit hiccup - /// into a vanished subscription would be the wrong trade — the identifier fallback stays for it. + /// The title a purchase falls back to when its product has no display name: the entitlement it + /// unlocks. A purchase is always shown; this only decides what heads the card. With no product + /// name and no entitlement either, the card has no title — never the raw product identifier, + /// which for a Stripe price reads `test:price_1Tu…:no-trial`. /// - /// Entitlement-only rows are outside this rule. They have no product to be named by, and the - /// entitlement's own identifier is what they show. - static func isNameable(_ product: ProductDisplayInfo?) -> Bool { - guard let product else { return true } - return product.hasDisplayName + /// A product can grant several entitlements; the lowest identifier is taken so the choice is + /// stable from one render to the next. + static func entitlementTitle(_ entitlements: Set) -> String? { + entitlements.map(\.id).sorted().first } func subscriptionPresentations( _ subscriptions: [SubscriptionTransaction], - products: [String: ProductDisplayInfo] + products: [String: ProductDisplayInfo], + entitlementsByProductId: [String: Set] = [:] ) -> [PurchasePresentation] { // `CustomerInfo.subscriptions` carries one entry per StoreKit transaction, which includes // every past renewal of a subscription. Collapse to one row per product: prefer the active @@ -85,10 +91,8 @@ struct PurchasePresentationBuilder { case (nil, nil): return lhs.purchaseDate < rhs.purchaseDate } } - return sorted.compactMap { sub in - let product = products[sub.productId] - guard Self.isNameable(product) else { return nil } - return presentation(for: sub, product: product) + return sorted.map { + presentation(for: $0, product: products[$0.productId], entitlements: entitlementsByProductId[$0.productId] ?? []) } } @@ -107,18 +111,18 @@ struct PurchasePresentationBuilder { func nonSubscriptionPresentations( _ purchases: [NonSubscriptionTransaction], - products: [String: ProductDisplayInfo] + products: [String: ProductDisplayInfo], + entitlementsByProductId: [String: Set] = [:] ) -> [PurchasePresentation] { - purchases.sorted { $0.purchaseDate < $1.purchaseDate }.compactMap { purchase -> PurchasePresentation? in + purchases.sorted { $0.purchaseDate < $1.purchaseDate }.map { purchase in let product = products[purchase.productId] - guard Self.isNameable(product) else { return nil } // Keyed by transaction id, not product id: consumables can legitimately be purchased // multiple times, and each purchase gets its own row. return PurchasePresentation( id: purchase.transactionId, kind: .nonSubscription(purchase), productId: purchase.productId, - title: product?.title ?? purchase.productId, + title: product?.title ?? Self.entitlementTitle(entitlementsByProductId[purchase.productId] ?? []), priceLine: product?.localizedPrice, statusLine: purchase.isRevoked ? strings.string("customer_center_revoked") @@ -133,7 +137,11 @@ struct PurchasePresentationBuilder { } } - private func presentation(for sub: SubscriptionTransaction, product: ProductDisplayInfo?) -> PurchasePresentation { + private func presentation( + for sub: SubscriptionTransaction, + product: ProductDisplayInfo?, + entitlements: Set + ) -> PurchasePresentation { let badge = badge(for: sub) let price = product?.localizedPrice let date = sub.expirationDate.map { dateFormatter.string(from: $0) } @@ -168,7 +176,7 @@ struct PurchasePresentationBuilder { id: sub.productId, kind: .subscription(sub), productId: sub.productId, - title: product?.title ?? sub.productId, + title: product?.title ?? Self.entitlementTitle(entitlements), priceLine: priceLine, statusLine: status, badge: badge, diff --git a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift index 0b9371e863..e22b38b0ed 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift @@ -10,15 +10,14 @@ import Foundation /// Display-oriented product info, decoupled from `StoreProduct` for testability. struct ProductDisplayInfo: Equatable { var productId: String - var title: String + /// The product's display name, or `nil` when neither StoreKit nor the catalogue supplied one. + /// Never the identifier standing in for one: a card without a name shows no name. + var title: String? var localizedPrice: String? var price: Decimal? var localizedPeriod: String? var subscriptionGroupId: String? var isAutoRenewable: Bool? - /// Whether `title` is a real display name rather than the product identifier standing in for - /// one. A purchase is only shown when this is `true` — see `PurchasePresentationBuilder`. - var hasDisplayName = true } enum PurchaseBadge: Equatable { @@ -35,7 +34,9 @@ struct PurchasePresentation: Identifiable, Equatable { var id: String var kind: PurchaseKind var productId: String? - var title: String + /// What the card is headed with: the product's display name, else the entitlement it unlocks, + /// else nothing. The purchase is always shown; only this label is allowed to be absent. + var title: String? var priceLine: String? var statusLine: String var badge: PurchaseBadge diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index 43dbeb9279..c9d8f442b6 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -93,13 +93,12 @@ extension ProductDisplayInfo { } self.init( productId: product.productIdentifier, - title: displayName ?? product.productIdentifier, + title: displayName, localizedPrice: product.localizedPrice, price: product.price, localizedPeriod: product.subscriptionPeriod == nil ? nil : product.period, subscriptionGroupId: product.subscriptionGroupIdentifier, - isAutoRenewable: isAutoRenewable, - hasDisplayName: displayName != nil + isAutoRenewable: isAutoRenewable ) } } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 8f47cfa6d1..32806a2d0c 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -79,7 +79,7 @@ struct PurchaseDetailScreenView: View { } } .listStyle(.insetGrouped) - .navigationTitle(purchase.title) + .navigationTitle(purchase.title ?? "") .navigationBarTitleDisplayMode(.inline) .onAppear { viewModel.surfaceDidAppear() } .onDisappear { viewModel.surfaceDidDisappear() } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift index 6e5e0d30df..1db80a35d7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PurchaseCardView.swift @@ -14,23 +14,26 @@ struct PurchaseCardView: View { @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, rowId: purchase.productId ?? purchase.id) - } - 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) + // The badge sits beside the text column rather than on a row of its own, so a card with no + // title — a web product the catalogue hasn't named — doesn't open with an empty line. The + // purchase is never hidden; only its title is allowed to be absent. + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + if let title = purchase.title { Text(title).font(.headline) } + if let price = purchase.priceLine { Text(price).font(.subheadline) } + Text(purchase.statusLine).font(.subheadline).foregroundStyle(.secondary) + if let key = purchase.storeLabelKey { + Text(strings.string(key)).font(.caption).foregroundStyle(.secondary) + } + if let refundResult, refundResult.productId == purchase.productId { + let isSuccess = refundResult.status == .success + Text(strings.string(isSuccess ? "customer_center_refund_success" : "customer_center_refund_error")) + .font(.caption) + .foregroundStyle(isSuccess ? Color.green : Color.red) + } } + Spacer(minLength: 8) + BadgeView(badge: purchase.badge, rowId: purchase.productId ?? purchase.id) } .padding(.vertical, 4) .accessibilityElement(children: .combine) diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 50dbfbab0e..2651c6cb1b 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -229,9 +229,10 @@ The Customer Center fires the following ``SuperwallEvent`` cases, which you can ## Limitations - Requires iOS 15.0+. On earlier versions, presentation calls are unavailable at compile time. -- A purchase whose product has no display name isn't shown. Today that is every web (Stripe, - Paddle) purchase, because the product catalogue doesn't return a name yet; they appear as soon - as it does. App Store purchases always have one. +- A purchase whose product has no display name is headed by the entitlement it unlocks, or by + nothing — never by the product identifier. Today that is every web (Stripe, Paddle) purchase, + because the product catalogue doesn't return a name yet; the name is used as soon as it does. + App Store purchases always have one. - 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/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index e3e414a29f..c4476671c2 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -115,10 +115,10 @@ struct PurchasePresentationBuilderTests { #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") + @Test("missing product shows no title and omits price") func missingProduct() { let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: [:]) - #expect(rows[0].title == "monthly") + #expect(rows[0].title == nil) #expect(rows[0].priceLine == nil) #expect(rows[0].statusLine == "Renews on 2023-11-15") } @@ -228,22 +228,29 @@ struct PurchasePresentationBuilderTests { #expect(rows.allSatisfy { $0.productId == "coins" }) } - // MARK: - A card needs a name + // MARK: - What heads the card /// What a Stripe purchase looks like today: the catalogue resolved the product and priced it, - /// but sent no display name. The alternative to hiding it is a card titled - /// `test:price_1Tu…:no-trial`, which is the thing this rule exists to prevent. - @Test("a subscription whose product resolved without a display name is not shown") - func unnamedProductHidesTheSubscription() { + /// but sent no display name. The purchase is shown regardless — hiding a paying customer's + /// subscription was never on the table — with the entitlement it unlocks as its title. + @Test("a subscription whose product has no name is titled by the entitlement it unlocks") + func unnamedProductFallsBackToEntitlement() { var unnamed = monthly - unnamed.title = "monthly" - unnamed.hasDisplayName = false - let rows = builder.build(customerInfo: info(subs: [sub("monthly")]), products: ["monthly": unnamed]) - #expect(rows.isEmpty) + unnamed.title = nil + let pro = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .stripe) + let rows = builder.build( + customerInfo: info(subs: [sub("monthly", store: .stripe)], entitlements: [pro]), + products: ["monthly": unnamed] + ) + #expect(rows.count == 1) + #expect(rows[0].title == "pro") + #expect(rows[0].priceLine == "$9.99 / month", "the price still shows") } - @Test("a one-off purchase whose product resolved without a display name is not shown") - func unnamedProductHidesTheOneOff() { + /// No product name and no entitlement either: the card is still there, just headed by nothing. + /// Never the product identifier — for a Stripe price that reads `test:price_1Tu…:no-trial`. + @Test("with neither a product name nor an entitlement, the card shows no title") + func noNameNoEntitlementShowsNoTitle() { let coins = NonSubscriptionTransaction( transactionId: "n", productId: "coins", @@ -254,31 +261,38 @@ struct PurchasePresentationBuilderTests { ) let unnamed = ProductDisplayInfo( productId: "coins", - title: "coins", + title: nil, localizedPrice: "$0.99", price: 0.99, localizedPeriod: nil, subscriptionGroupId: nil, - isAutoRenewable: false, - hasDisplayName: false + isAutoRenewable: false ) let rows = builder.build(customerInfo: info(nonSubs: [coins]), products: ["coins": unnamed]) - #expect(rows.isEmpty) + #expect(rows.count == 1, "the purchase is never hidden") + #expect(rows[0].title == nil) + #expect(rows[0].priceLine == "$0.99") } - /// The rule is per card. One unnamed product must not take a named one down with it — and - /// `missingProduct` above pins the other edge: a product that didn't resolve at all keeps its - /// identifier, because that's a lookup failure rather than a naming decision. - @Test("hiding is per card, not all-or-nothing") - func hidingIsPerCard() { - var unnamed = monthly - unnamed.productId = "web" - unnamed.title = "web" - unnamed.hasDisplayName = false + /// A product can grant several entitlements. Pick one stably, so the title doesn't change + /// between renders. + @Test("several entitlements pick the lowest identifier, every time") + func entitlementFallbackIsStable() { + let set: Set = [ + Entitlement(id: "pro", isActive: true, store: .stripe), + Entitlement(id: "beta", isActive: true, store: .stripe) + ] + #expect(PurchasePresentationBuilder.entitlementTitle(set) == "beta") + #expect(PurchasePresentationBuilder.entitlementTitle([]) == nil) + } + + @Test("a product name wins over the entitlement") + func productNameWinsOverEntitlement() { + let pro = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .appStore) let rows = builder.build( - customerInfo: info(subs: [sub("monthly"), sub("web", store: .stripe)]), - products: ["monthly": monthly, "web": unnamed] + customerInfo: info(subs: [sub("monthly")], entitlements: [pro]), + products: ["monthly": monthly] ) - #expect(rows.map(\.id) == ["monthly"]) + #expect(rows[0].title == "Monthly") } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift index 36b2534c7f..a9954fd69a 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift @@ -56,25 +56,27 @@ struct WebProductPricingTests { #expect(display.localizedPeriod != nil, "the renewal line reads better with a period") } - /// A catalogue product with no display name yields no card. The price is there — that's what - /// the catalogue is for — but a card is titled with its product's name, and a card titled with - /// `test:price_…:no-trial` is worse than no card. The name is the backend's to send. - @Test("a web product with no display name is not shown", arguments: [199, 999, 7999]) - func unnamedWebProductIsHidden(amountInCents: Int) throws { + /// A catalogue product with no display name still gets its card — the price is there, and + /// that's what the catalogue is for. What it doesn't get is a title made from the identifier: + /// `test:price_…:no-trial` is not a name. The name is the backend's to send. + @Test("a web product with no display name keeps its card and shows no title", arguments: [199, 999, 7999]) + func unnamedWebProductShowsNoTitle(amountInCents: Int) throws { let product = try decodeProduct(amountInCents: amountInCents) let storeProduct = StoreProduct( catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: []) ) let display = ProductDisplayInfo(storeProduct) #expect(display.localizedPrice?.contains(".") == true, "the price itself resolved") - #expect(!display.hasDisplayName) + #expect(display.title == nil) let builder = PurchasePresentationBuilder(strings: .english, locale: Locale(identifier: "en_US")) let presentations = builder.build( customerInfo: CustomerInfo(subscriptions: [webSubscription()], nonSubscriptions: [], entitlements: []), products: ["web_pro_monthly": display] ) - #expect(presentations.isEmpty, "no name, no card") + let card = try #require(presentations.first, "the purchase is never hidden") + #expect(card.title == nil) + #expect(card.priceLine != nil) } /// The same purchase once the catalogue names it: the card appears, titled by the catalogue, @@ -126,7 +128,7 @@ struct WebProductPricingTests { ) #expect(ProductDisplayInfo(storeProduct, name: product.name).title == "Pro") - #expect(ProductDisplayInfo(storeProduct).title == "web_pro_monthly", "no name given, no name used") + #expect(ProductDisplayInfo(storeProduct).title == nil, "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 84e7dc141f..c8c9b0b9df 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") + /// The identifier is never promoted to a title. A product with no name has no name. + @Test("ProductDisplayInfo init: title is nil when the sk1 title is empty") func productDisplayInfoFromSK1WithoutTitle() { let sk1 = MockSkProduct(productIdentifier: "monthly") let storeProduct = StoreProduct(sk1Product: sk1, entitlements: []) let info = ProductDisplayInfo(storeProduct) - #expect(info.title == "monthly") + #expect(info.title == nil) + #expect(info.productId == "monthly") } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift index e94deec1b7..f4482d1dd0 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift @@ -140,12 +140,12 @@ struct DesignReviewSnapshots { subscriptionGroupId: nil, isAutoRenewable: false ), - // A web product as it will arrive once `/v1/products` carries a display name. Until it does, - // a web purchase has no name and therefore no card — see `PurchasePresentationBuilder` — so - // the unnamed shape isn't worth a screenshot: it renders as an empty management screen. + // A web product as it arrives today: the catalogue supplies its price but `/v1/products` + // carries no display name, so the card shows no title (the fixture's `CustomerInfo` carries + // no entitlement for it either — with one, that entitlement would head the card instead). "web_pro_monthly": .init( productId: "web_pro_monthly", - title: "Pro", + title: nil, localizedPrice: "$12.99", price: 12.99, localizedPeriod: "month", From 95baa1cc0ba8b542b3bc6f0866578f4d4611d21d Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 11 Sep 2026 14:58:48 -0500 Subject: [PATCH 61/64] fix(customer-center): give entitlement-only purchases their detail screen back, and close three review threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making every subscription a drill-down (d0be8f8) split rows on `subscription != nil`, and an entitlement-only purchase has no `SubscriptionTransaction` — so it fell into the plain-card section, the root stopped passing a purchase to the resolver, and the "Manage subscription" row for a Stripe customer who arrived as a bare entitlement became unreachable. That is the exact customer be729b8 fixed the resolver for. Rows now split on `opensDetail`, which is true for subscriptions and entitlement-only purchases and false for one-offs, which have no action of their own. Three review threads closed alongside: - `CatalogueCache` is an actor, and actors are reentrant across `await`: two `apply` calls overlapping on a cold cache both passed the freshness check and both fetched. An in-flight task is now handed to whoever arrives during it; the test races two callers and counts one fetch. - The Objective-C `url` factory put the title in the enum only, and `type` is `@nonobjc` — so an ObjC caller could set a title and never read it back. It now sets `Path.title` too. - Seven string keys were orphaned when `PurchaseDetailRows` went, across English and 41 locales. Removed. A comment in `PathsListView` still named the deleted "See all purchases" row. The DocC limitation stops claiming every App Store product has a display name: StoreKit reports an empty one until the product is localized. Co-Authored-By: Claude Fable 5.1 --- .../CustomerCenterConfiguration+ObjC.swift | 9 ++++-- .../Models/PurchasePresentation.swift | 12 ++++++++ .../CustomerCenterDependencies.swift | 18 ++++++++++-- .../Views/CustomerCenterStrings+English.swift | 7 ----- .../Views/ManagementScreenView.swift | 11 ++++---- .../CustomerCenter/Views/PathsListView.swift | 4 +-- .../Documentation.docc/CustomerCenter.md | 3 +- .../ar.lproj/Localizable.strings | 7 ----- .../ca.lproj/Localizable.strings | 7 ----- .../cs.lproj/Localizable.strings | 7 ----- .../da.lproj/Localizable.strings | 7 ----- .../de.lproj/Localizable.strings | 7 ----- .../el.lproj/Localizable.strings | 7 ----- .../en.lproj/Localizable.strings | 7 ----- .../en_AU.lproj/Localizable.strings | 7 ----- .../en_GB.lproj/Localizable.strings | 7 ----- .../es.lproj/Localizable.strings | 7 ----- .../es_419.lproj/Localizable.strings | 7 ----- .../fi.lproj/Localizable.strings | 7 ----- .../fr.lproj/Localizable.strings | 7 ----- .../fr_CA.lproj/Localizable.strings | 7 ----- .../he.lproj/Localizable.strings | 7 ----- .../hi.lproj/Localizable.strings | 7 ----- .../hr.lproj/Localizable.strings | 7 ----- .../hu.lproj/Localizable.strings | 7 ----- .../id.lproj/Localizable.strings | 7 ----- .../it.lproj/Localizable.strings | 7 ----- .../ja.lproj/Localizable.strings | 7 ----- .../ko.lproj/Localizable.strings | 7 ----- .../ms.lproj/Localizable.strings | 7 ----- .../nb.lproj/Localizable.strings | 7 ----- .../nl.lproj/Localizable.strings | 7 ----- .../nn.lproj/Localizable.strings | 7 ----- .../pl.lproj/Localizable.strings | 7 ----- .../pt.lproj/Localizable.strings | 7 ----- .../pt_BR.lproj/Localizable.strings | 7 ----- .../pt_PT.lproj/Localizable.strings | 7 ----- .../ro.lproj/Localizable.strings | 7 ----- .../ru.lproj/Localizable.strings | 7 ----- .../sk.lproj/Localizable.strings | 7 ----- .../sl.lproj/Localizable.strings | 7 ----- .../sv.lproj/Localizable.strings | 7 ----- .../th.lproj/Localizable.strings | 7 ----- .../tr.lproj/Localizable.strings | 7 ----- .../uk.lproj/Localizable.strings | 7 ----- .../vi.lproj/Localizable.strings | 7 ----- .../zh_Hans.lproj/Localizable.strings | 7 ----- .../zh_Hant.lproj/Localizable.strings | 7 ----- .../Logic/CatalogueCacheTests.swift | 28 +++++++++++++++++++ .../PurchasePresentationBuilderTests.swift | 22 +++++++++++++++ 50 files changed, 94 insertions(+), 307 deletions(-) diff --git a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift index 6f8fb73301..5129198b99 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+ObjC.swift @@ -68,9 +68,14 @@ extension CustomerCenterConfiguration.Path { .init(id: id, type: .contactSupport, title: title) } /// `title` is non-optional here, unlike the other path factories: a URL row has no default - /// name to fall back on. + /// name to fall back on. It is set on the path as well as in the case, because `type` is + /// `@nonobjc` — without this, an Objective-C caller could hand a title in and never read it back. @objc public static func url(id: String, url: URL, openMethod: CustomerCenterOpenMethodObjc, title: String) -> CustomerCenterConfiguration.Path { - .init(id: id, type: .url(url, title: title, openMethod: openMethod == .external ? .external : .inApp)) + .init( + id: id, + type: .url(url, title: title, openMethod: openMethod == .external ? .external : .inApp), + title: title + ) } @objc public static func custom(id: String, identifier: String, title: String?) -> CustomerCenterConfiguration.Path { .init(id: id, type: .custom(identifier: identifier), title: title) diff --git a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift index e22b38b0ed..c04fd938eb 100644 --- a/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift +++ b/Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift @@ -50,4 +50,16 @@ struct PurchasePresentation: Identifiable, Equatable { if case .subscription(let sub) = kind { return sub } return nil } + + /// Whether the row opens a detail screen — which is where a purchase's own actions live. + /// + /// A subscription does. So does an entitlement-only purchase: a web subscription arrives as a + /// bare entitlement whenever the backend sends no matching transaction, and that customer still + /// has a management page to reach. Splitting on `subscription != nil` alone stranded them — + /// the only row that could carry the management link had nowhere to open. A one-off purchase + /// has no action of its own, so it stays a plain card. + var opensDetail: Bool { + if case .nonSubscription = kind { return false } + return true + } } diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift index c9d8f442b6..42082ad27b 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift @@ -211,6 +211,11 @@ actor CatalogueCache { static let ttl: TimeInterval = 5 * 60 private var cached: (response: SuperwallProductsResponse, at: Date)? + /// The fetch in progress, if any, so a caller arriving mid-flight shares it rather than starting + /// its own. An actor is reentrant across `await`: without this, two `apply` calls overlapping on + /// a cold cache — `load()` and the customer-info publisher can do exactly that — both pass the + /// freshness check and both fetch. + private var inFlight: Task? private let now: () -> Date init(now: @escaping () -> Date = Date.init) { @@ -218,14 +223,21 @@ actor CatalogueCache { } /// Returns the cached catalogue when it is still fresh, otherwise awaits `fetch` and keeps it. - /// A throwing `fetch` is not cached — a failed load should be retried, not remembered. + /// A throwing `fetch` is not cached — a failed load should be retried, not remembered — and + /// every caller sharing that flight sees the same error. func products( - fetch: () async throws -> SuperwallProductsResponse + fetch: @escaping () async throws -> SuperwallProductsResponse ) async throws -> SuperwallProductsResponse { if let cached, isFresh(cached.at) { return cached.response } - let response = try await fetch() + if let inFlight { + return try await inFlight.value + } + let task = Task { try await fetch() } + inFlight = task + defer { inFlight = nil } + let response = try await task.value cached = (response, now()) return response } diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index b8af7d8515..3d1d0aeb5b 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -71,8 +71,6 @@ let englishStrings: [String: String] = [ "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", @@ -96,11 +94,6 @@ let englishStrings: [String: String] = [ "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_offer": "Offer", // Customer Center – restore "customer_center_restoring": "Restoring…", "customer_center_restore_success_title": "Purchases restored", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 32806a2d0c..aaeeb90c3f 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -13,8 +13,8 @@ struct ManagementScreenView: View { @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 } } + private var subscriptions: [PurchasePresentation] { viewModel.purchases.filter(\.opensDetail) } + private var others: [PurchasePresentation] { viewModel.purchases.filter { !$0.opensDetail } } var body: some View { List { @@ -25,9 +25,10 @@ struct ManagementScreenView: View { DuplicateSubscriptionBanner() } if !subscriptions.isEmpty { - // Every subscription is a row that opens its own detail screen, one or many alike. This - // screen keeps the actions that apply to the account; anything that only makes sense - // against one subscription — change plan, refund, cancel — lives where the row leads. + // Every subscription — and every entitlement-only purchase, see `opensDetail` — is a row + // that opens its own detail screen, one or many alike. This screen keeps the actions that + // apply to the account; anything that only makes sense against one purchase — change + // plan, refund, cancel, the web management page — lives where the row leads. Section(strings.string("customer_center_section_subscriptions")) { ForEach(subscriptions) { purchase in CustomerCenterDrillDown { diff --git a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift index 7eb8327cb2..82dc305fec 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/PathsListView.swift @@ -28,8 +28,8 @@ struct PathsListView: View { } 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. + // push — the per-subscription detail rows — are `NavigationLink`s and get their chevron + // from SwiftUI. HStack { Text(title(for: resolved)) Spacer() diff --git a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md index 2651c6cb1b..9508e5d2a2 100644 --- a/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md +++ b/Sources/SuperwallKit/Documentation.docc/CustomerCenter.md @@ -232,7 +232,8 @@ The Customer Center fires the following ``SuperwallEvent`` cases, which you can - A purchase whose product has no display name is headed by the entitlement it unlocks, or by nothing — never by the product identifier. Today that is every web (Stripe, Paddle) purchase, because the product catalogue doesn't return a name yet; the name is used as soon as it does. - App Store purchases always have one. + An App Store product has one once it is localized in App Store Connect — before that, StoreKit + reports an empty display name, which is treated the same way. - Promotional offers are not yet supported as a Customer Center path. - Remote configuration of the Customer Center from the Superwall dashboard is coming; today it's configured entirely in code via ``SuperwallOptions/customerCenter``. diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 6482a40095..0f922e5e00 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "نشط"; @@ -101,11 +99,6 @@ "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_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 3d571fd973..cc97744070 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 966b2b4140..ea99735aaf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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í"; @@ -101,11 +99,6 @@ "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_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 579bdff0d0..38eb1252d5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 6c52d5e50e..9416e9d460 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 9a571babdd..feccea326b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "Ενεργή"; @@ -101,11 +99,6 @@ "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_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 40256c0535..7938bb55cd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 40256c0535..7938bb55cd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 40256c0535..7938bb55cd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 9392ff2efb..73958ec13e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 2ab100a551..89b0710d20 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 7f16b697fe..77b26dd4f9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 779c174d3d..735ac8ea7b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 ae5e9bf282..5f9aa7f1aa 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 cb7eccf646..7d0eb3086b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "פעיל"; @@ -101,11 +99,6 @@ "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_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 719be2893d..f17740c110 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "सक्रिय"; @@ -101,11 +99,6 @@ "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_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 a443360019..b10bbf6ae5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 29496b1ba6..b33f35b39a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 b7ee50193b..cd98d4e715 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 1371745ab3..0d6fd6d425 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 3f7223a089..827a1b9c1f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "有効"; @@ -101,11 +99,6 @@ "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_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 e005378d72..688d4dbab4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "활성"; @@ -101,11 +99,6 @@ "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_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 597f7311c6..cd0e015257 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 40ce23a6d4..89646b96a4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 798a23dfc0..b6ef9f05b6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 67f783db70..c11c861c93 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 ff6941d794..74b4858838 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 836ba47690..e37e6fb611 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 d312e48612..5654e6f244 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 7db3ee8d15..29934bec03 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 8e5a910b43..a4327cd991 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 019bc9959c..a1c5b1aa43 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "Активна"; @@ -101,11 +99,6 @@ "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_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 4547746aa2..c219733a4f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 d3b08fd3de..12b50db938 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 7b3c6ced7e..83bbc8c6c2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 cfd18c5deb..7fbe5e5b5d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "ใช้งานอยู่"; @@ -101,11 +99,6 @@ "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_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 3e30590b6f..0184149d80 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 672cb74b37..b4d808c437 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "Активна"; @@ -101,11 +99,6 @@ "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_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 bc91d0a2b2..1cd78ee639 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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"; @@ -101,11 +99,6 @@ "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_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 cd904b93c0..60c152f2e6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "有效"; @@ -101,11 +99,6 @@ "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_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 13389e4f7e..d9bc1a7181 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -73,8 +73,6 @@ "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" = "有效"; @@ -101,11 +99,6 @@ "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_offer" = "優惠"; /* Customer Center – restore */ "customer_center_restoring" = "正在恢復…"; diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift index 7116c8de7b..7c8da534c2 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/CatalogueCacheTests.swift @@ -63,4 +63,32 @@ struct CatalogueCacheTests { #expect(fetches == 2) } + + /// An actor is reentrant across `await`. Without an in-flight handoff, two callers landing on a + /// cold cache both pass the freshness check and both fetch — on the Customer Center that is + /// `load()` and the customer-info publisher calling `apply` at nearly the same moment. + @available(iOS 15.0, *) + @Test("callers overlapping on a cold cache share one fetch") + func overlappingCallersShareOneFetch() async throws { + let cache = CatalogueCache() + let fetches = Counter() + + async let first = cache.products { + await fetches.increment() + try await Task.sleep(nanoseconds: 150_000_000) + return self.response() + } + async let second = cache.products { + await fetches.increment() + return self.response() + } + _ = try await (first, second) + + #expect(await fetches.value == 1) + } +} + +private actor Counter { + var value = 0 + func increment() { value += 1 } } diff --git a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift index c4476671c2..163fd582ed 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/Logic/PurchasePresentationBuilderTests.swift @@ -286,6 +286,28 @@ struct PurchasePresentationBuilderTests { #expect(PurchasePresentationBuilder.entitlementTitle([]) == nil) } + /// The management screen only gives a detail screen — and so a place for the purchase's own + /// actions — to rows that open one. A bare entitlement has to be among them: a web subscriber + /// whose backend sent no transaction is one, and their management link lives on that screen. + @Test("subscriptions and entitlement-only purchases open a detail screen; one-offs don't") + func whichRowsOpenDetail() { + let nonSub = NonSubscriptionTransaction( + transactionId: "n", + productId: "coins", + purchaseDate: now, + isConsumable: true, + isRevoked: false, + store: .appStore + ) + let bareWebEntitlement = Entitlement(id: "pro", isActive: true, store: .stripe) + let rows = builder.build( + customerInfo: info(subs: [sub("monthly")], nonSubs: [nonSub], entitlements: [bareWebEntitlement]), + products: [:] + ) + #expect(rows.map(\.id) == ["monthly", "n", "entitlement:pro"]) + #expect(rows.map(\.opensDetail) == [true, false, true]) + } + @Test("a product name wins over the entitlement") func productNameWinsOverEntitlement() { let pro = Entitlement(id: "pro", isActive: true, productIds: ["monthly"], store: .appStore) From 207663b44c2620cff246deb9272ad33bdef6ae9b Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 11 Sep 2026 15:27:30 -0500 Subject: [PATCH 62/64] fix(customer-center): a detail screen with nothing to act on says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every subscription row opens its detail screen — that is the rule, and it holds whether or not there is anything left to do. But the screen's only content below the card was the resolved actions, and under the shipped default configuration several realistic purchases resolve to none: a revoked App Store subscription, a lapsed web one, anything from the Play Store or another store, a comped grant with no management page. Each opened onto an "Actions" heading over an empty list. The view model now answers `hasActions(for:)` — the same resolver call the list makes — and the detail screen shows the actions when there are any and a single line saying there is nothing to manage when there aren't. The row still opens; what changed is what it opens onto. Tests drive that decision from `.default` rather than a helper that pre-sets a support email, which is what had made the resolver tests read as non-empty for these shapes. The new string is added to English and all 41 locales in English, to keep key parity; it needs translating. Co-Authored-By: Claude Fable 5.1 --- .../ViewModel/CustomerCenterViewModel.swift | 12 ++ .../Views/CustomerCenterStrings+English.swift | 1 + .../Views/ManagementScreenView.swift | 15 ++- .../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 + SuperwallKit.xcodeproj/project.pbxproj | 4 + .../PurchaseDetailActionsTests.swift | 107 ++++++++++++++++++ 46 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index 119ed55afb..beb4e05cf1 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -318,6 +318,18 @@ final class CustomerCenterViewModel: ObservableObject { } await apply(customerInfo: info, refetchProducts: true) } + + /// Whether the detail screen for `purchase` has anything to act on. + /// + /// Every subscription row opens its detail screen; that is the rule, and it holds whether or + /// not there is anything left to do. This decides what the screen shows once it is open: the + /// resolved actions, or a line saying there are none — a revoked App Store subscription, a + /// lapsed web one, a purchase from another store, or a comped grant with no management page + /// all resolve to nothing under the default configuration, and an "Actions" heading over an + /// empty list is worse than saying so. + func hasActions(for purchase: PurchasePresentation) -> Bool { + !paths(for: purchase, isScreenLevel: false).isEmpty + } } // MARK: - Visibility-driven dismissal diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index 3d1d0aeb5b..f47cbf8896 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -50,6 +50,7 @@ let englishStrings: [String: String] = [ "customer_center_path_manage_subscription": "Cancel subscription", "customer_center_path_manage_subscription_web": "Manage subscription", "customer_center_web_manage_unavailable": "Manage your subscription using the link in your emailed receipt.", + "customer_center_detail_nothing_to_manage": "There's nothing to manage for this purchase.", "customer_center_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/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index aaeeb90c3f..044f4819a9 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -75,8 +75,19 @@ struct PurchaseDetailScreenView: View { var body: some View { List { Section { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) } - Section(strings.string("customer_center_section_actions")) { - PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) + if viewModel.hasActions(for: purchase) { + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) + } + } else { + // The row opened this screen regardless — see `hasActions(for:)` — so say what there is + // to say rather than head an empty list with "Actions". + Section { + Text(strings.string("customer_center_detail_nothing_to_manage")) + .font(.subheadline) + .foregroundStyle(.secondary) + .accessibilityIdentifier("customer_center.detail.nothing_to_manage") + } } } .listStyle(.insetGrouped) diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index 0f922e5e00..a439d7716b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "طلب دعم"; "customer_center_support_body" = "يرجى وصف مشكلتك أو سؤالك."; "customer_center_no_mail_app" = "لا يوجد تطبيق بريد مُهيأ على هذا الجهاز. يمكنك التواصل معنا عبر %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index cc97744070..1f1b86a894 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Sol·licitud d'assistència"; "customer_center_support_body" = "Descriu el teu problema o dubte."; "customer_center_no_mail_app" = "Aquest dispositiu no té cap aplicació de correu configurada. Ens pots contactar a %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index ea99735aaf..e779d0ec57 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Žádost o podporu"; "customer_center_support_body" = "Popište prosím svůj problém nebo dotaz."; "customer_center_no_mail_app" = "V tomto zařízení není nastavena žádná e-mailová aplikace. Můžete nás kontaktovat na %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 38eb1252d5..81e78acec9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Supportanmodning"; "customer_center_support_body" = "Beskriv venligst dit problem eller spørgsmål."; "customer_center_no_mail_app" = "Der er ikke konfigureret en mailapp på denne enhed. Du kan kontakte os på %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 9416e9d460..233aa57bc6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support-Anfrage"; "customer_center_support_body" = "Bitte beschreiben Sie Ihr Problem oder Ihre Frage."; "customer_center_no_mail_app" = "Auf diesem Gerät ist keine Mail-App eingerichtet. Sie erreichen uns unter %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index feccea326b..a71741ecf4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Αίτημα υποστήριξης"; "customer_center_support_body" = "Περιγράψτε το πρόβλημα ή την ερώτησή σας."; "customer_center_no_mail_app" = "Δεν έχει ρυθμιστεί εφαρμογή αλληλογραφίας σε αυτή τη συσκευή. Μπορείτε να επικοινωνήσετε μαζί μας στο %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 7938bb55cd..3670e26068 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support request"; "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 7938bb55cd..3670e26068 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support request"; "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 7938bb55cd..3670e26068 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support request"; "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index 73958ec13e..ef6d71ecbe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Solicitud de soporte"; "customer_center_support_body" = "Describa su problema o pregunta."; "customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puede contactarnos en %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index 89b0710d20..b3d1da552b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Solicitud de soporte"; "customer_center_support_body" = "Describe tu problema o pregunta."; "customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puedes contactarnos en %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 77b26dd4f9..9ce1f8b86a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Tukipyyntö"; "customer_center_support_body" = "Kuvaile ongelmaasi tai kysymystäsi."; "customer_center_no_mail_app" = "Tähän laitteeseen ei ole määritetty sähköpostisovellusta. Voit tavoittaa meidät osoitteessa %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 735ac8ea7b..467f92cb86 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Demande d'assistance"; "customer_center_support_body" = "Merci de décrire votre problème ou votre question."; "customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index 5f9aa7f1aa..b9db1093eb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Demande d'assistance"; "customer_center_support_body" = "Merci de décrire votre problème ou votre question."; "customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 7d0eb3086b..4df13b7dd9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "בקשת תמיכה"; "customer_center_support_body" = "אנא תאר את הבעיה או השאלה שלך."; "customer_center_no_mail_app" = "לא הוגדרה אפליקציית אימייל במכשיר זה. תוכל ליצור איתנו קשר בכתובת %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index f17740c110..e34933aaa5 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "सहायता अनुरोध"; "customer_center_support_body" = "कृपया अपनी समस्या या प्रश्न का वर्णन करें।"; "customer_center_no_mail_app" = "इस डिवाइस पर कोई मेल ऐप कॉन्फ़िगर नहीं है। आप हमसे %@ पर संपर्क कर सकते हैं।"; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index b10bbf6ae5..133c0b45ec 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Zahtjev za podršku"; "customer_center_support_body" = "Opišite svoj problem ili pitanje."; "customer_center_no_mail_app" = "Na ovom uređaju nije postavljena aplikacija za e-poštu. Možete nas kontaktirati na %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index b33f35b39a..f9b1ff7526 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Támogatási kérelem"; "customer_center_support_body" = "Kérjük, írja le a problémáját vagy kérdését."; "customer_center_no_mail_app" = "Ezen az eszközön nincs beállítva levelezőalkalmazás. Elérhet minket a következő címen: %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index cd98d4e715..c1fe57e1c2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Permintaan dukungan"; "customer_center_support_body" = "Silakan jelaskan masalah atau pertanyaan Anda."; "customer_center_no_mail_app" = "Tidak ada aplikasi email yang dikonfigurasi di perangkat ini. Anda dapat menghubungi kami di %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index 0d6fd6d425..a91df5bc1d 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Richiesta di assistenza"; "customer_center_support_body" = "Descrivi il tuo problema o la tua domanda."; "customer_center_no_mail_app" = "Su questo dispositivo non è configurata alcuna app di posta. Puoi contattarci all'indirizzo %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index 827a1b9c1f..d1dfa5aefb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "サポートリクエスト"; "customer_center_support_body" = "問題やご質問の内容をご記入ください。"; "customer_center_no_mail_app" = "このデバイスにはメールアプリが設定されていません。%@までご連絡ください。"; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index 688d4dbab4..ba61d8f5db 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "지원 요청"; "customer_center_support_body" = "문제나 질문을 설명해 주세요."; "customer_center_no_mail_app" = "이 기기에 메일 앱이 설정되어 있지 않습니다. %@로 문의해 주세요."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index cd0e015257..d550447f8c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Permohonan sokongan"; "customer_center_support_body" = "Sila terangkan masalah atau soalan anda."; "customer_center_no_mail_app" = "Tiada aplikasi mel dikonfigurasikan pada peranti ini. Anda boleh menghubungi kami di %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index 89646b96a4..b9fde6ede3 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support-forespørsel"; "customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; "customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index b6ef9f05b6..e581e54f94 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Ondersteuningsverzoek"; "customer_center_support_body" = "Beschrijf uw probleem of vraag."; "customer_center_no_mail_app" = "Er is geen mail-app geconfigureerd op dit apparaat. U kunt ons bereiken via %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index c11c861c93..3404c2a4d8 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Support-forespørsel"; "customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; "customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 74b4858838..1454711fc0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Zgłoszenie do pomocy technicznej"; "customer_center_support_body" = "Opisz swój problem lub pytanie."; "customer_center_no_mail_app" = "Na tym urządzeniu nie skonfigurowano aplikacji pocztowej. Możesz się z nami skontaktować pod adresem %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index e37e6fb611..d156f783ab 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 5654e6f244..50fc0bebbe 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index 29934bec03..4a33bc3ea0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index a4327cd991..7530680cdc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Solicitare de asistență"; "customer_center_support_body" = "Vă rugăm să descrieți problema sau întrebarea dvs."; "customer_center_no_mail_app" = "Nu este configurată nicio aplicație de e-mail pe acest dispozitiv. Ne puteți contacta la %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index a1c5b1aa43..184f5334fd 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Запрос в поддержку"; "customer_center_support_body" = "Пожалуйста, опишите вашу проблему или вопрос."; "customer_center_no_mail_app" = "На этом устройстве не настроено почтовое приложение. Вы можете связаться с нами по адресу %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index c219733a4f..2e8e20c4d0 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Žiadosť o podporu"; "customer_center_support_body" = "Opíšte, prosím, váš problém alebo otázku."; "customer_center_no_mail_app" = "V tomto zariadení nie je nastavená žiadna e-mailová aplikácia. Môžete nás kontaktovať na %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index 12b50db938..e5e415a178 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Zahteva za podporo"; "customer_center_support_body" = "Opišite svojo težavo ali vprašanje."; "customer_center_no_mail_app" = "V tej napravi ni nastavljena aplikacija za e-pošto. Lahko nas kontaktirate na %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index 83bbc8c6c2..ed6eac3a66 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Supportförfrågan"; "customer_center_support_body" = "Beskriv ditt problem eller din fråga."; "customer_center_no_mail_app" = "Ingen e-postapp är konfigurerad på den här enheten. Du kan nå oss på %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index 7fbe5e5b5d..e09041050b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "คำขอการสนับสนุน"; "customer_center_support_body" = "โปรดอธิบายปัญหาหรือคำถามของคุณ"; "customer_center_no_mail_app" = "ไม่มีแอปอีเมลที่ตั้งค่าไว้บนอุปกรณ์นี้ คุณสามารถติดต่อเราได้ที่ %@"; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 0184149d80..8db8d69f8b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Destek talebi"; "customer_center_support_body" = "Lütfen sorununuzu veya sorunuzu açıklayın."; "customer_center_no_mail_app" = "Bu cihazda yapılandırılmış bir posta uygulaması yok. Bize %@ adresinden ulaşabilirsiniz."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index b4d808c437..613f06d995 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Запит до підтримки"; "customer_center_support_body" = "Будь ласка, опишіть вашу проблему або запитання."; "customer_center_no_mail_app" = "На цьому пристрої не налаштовано жодного поштового застосунку. Ви можете зв'язатися з нами за адресою %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index 1cd78ee639..a37ff4923c 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "Yêu cầu hỗ trợ"; "customer_center_support_body" = "Vui lòng mô tả vấn đề hoặc câu hỏi của bạn."; "customer_center_no_mail_app" = "Không có ứng dụng thư nào được định cấu hình trên thiết bị này. Bạn có thể liên hệ với chúng tôi tại %@."; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 60c152f2e6..9493ff2f11 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "支持请求"; "customer_center_support_body" = "请描述您的问题或疑问。"; "customer_center_no_mail_app" = "此设备未配置邮件应用。您可以通过 %@ 联系我们。"; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index d9bc1a7181..14f817b9b7 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -125,3 +125,4 @@ "customer_center_support_subject" = "支援請求"; "customer_center_support_body" = "請描述您的問題或疑問。"; "customer_center_no_mail_app" = "此裝置未設定郵件應用程式。您可以透過 %@ 與我們聯絡。"; +"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 3851e2d89e..ab1a06bdaf 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -88,6 +88,7 @@ 236D81432A50D722A9702C38 /* PaywallBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2955F8306C59091AEA338E0D /* PaywallBillingPlanTests.swift */; }; 23CD6038DD65F057C81A412D /* PaywallManagerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */; }; 2428529A6B2B6E873DEC22E8 /* Assignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA9100DDAD2E8596F96A1BCB /* Assignment.swift */; }; + 24D4B587A3D2FF168587117F /* PurchaseDetailActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */; }; 2517FC60F3A7288C5FE34A73 /* CustomProductTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD32AF04F6FB9601759E529 /* CustomProductTests.swift */; }; 252D37DDAA2C97A6E2DDD6B7 /* SurveyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 655E5AE73EF5723A28D2EADD /* SurveyTests.swift */; }; 25E2A4570B63FE36E4DD4E52 /* TemplateLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E541F079BC78206BC44D6E /* TemplateLogic.swift */; }; @@ -1253,6 +1254,7 @@ E0A7F2B0E53BE42DC6B52873 /* EntitlementsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementsStatus.swift; sourceTree = ""; }; E19BDEFD1BAB331A814E95CE /* CustomerCenterManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterManagerTests.swift; sourceTree = ""; }; E1C8B2F4853060258BC2CBD9 /* VerificationResult+Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerificationResult+Transaction.swift"; sourceTree = ""; }; + E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseDetailActionsTests.swift; sourceTree = ""; }; E2243C6BF6BE477794F568ED /* GCControllerElement+buttonName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GCControllerElement+buttonName.swift"; sourceTree = ""; }; E23F2FE294EBC63F81786A85 /* PresentationItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationItems.swift; sourceTree = ""; }; E2915A802FACB53B6094B011 /* ManifestDataFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManifestDataFetcher.swift; sourceTree = ""; }; @@ -2942,6 +2944,7 @@ B6F403BB2165F528F3C40339 /* CustomerCenterDependenciesMocks.swift */, FAE22269D7130B4666F2A13F /* CustomerCenterDependenciesTests.swift */, 59AADECFD10DA2D0BC3FFF1D /* CustomerCenterViewModelTests.swift */, + E2171E7A973928A9BA136558 /* PurchaseDetailActionsTests.swift */, ); path = ViewModel; sourceTree = ""; @@ -3685,6 +3688,7 @@ 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, + 24D4B587A3D2FF168587117F /* PurchaseDetailActionsTests.swift in Sources */, D11EE875D4F3B3AC212526CD /* PurchasePresentationBuilderTests.swift in Sources */, 5EF4EE04BAA930A2CC4379A1 /* RawWebMessageHandlerTests.swift in Sources */, 3BA17B2DA6B69A7B90D39AF9 /* ReceiptManagerTests.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift new file mode 100644 index 0000000000..5f52065a7e --- /dev/null +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift @@ -0,0 +1,107 @@ +// +// PurchaseDetailActionsTests.swift +// +// +// Created by Jordan Morgan on 11/09/2026. +// + +import Testing +import Foundation +@testable import SuperwallKit + +/// Every subscription row opens its detail screen. These pin what that screen has to say once +/// it is open, driven from the shipped `.default` configuration — no support email, no web +/// management URL — because that is the configuration under which several realistic purchases +/// resolve to no actions at all, and a heading over an empty list is not an acceptable answer. +@Suite("Purchase detail: actions or an explanation") +@MainActor +struct PurchaseDetailActionsTests { + private func subscription( + store: ProductStore = .appStore, + isActive: Bool = true, + isRevoked: Bool = false + ) -> SubscriptionTransaction { + SubscriptionTransaction( + transactionId: "t1", + productId: "pro_monthly", + purchaseDate: Date().addingTimeInterval(-30 * 86_400), + willRenew: isActive, + isRevoked: isRevoked, + isInGracePeriod: false, + isInBillingRetryPeriod: false, + isActive: isActive, + expirationDate: Date().addingTimeInterval(isActive ? 12 * 86_400 : -86_400), + subscriptionGroupId: "group_pro", + store: store + ) + } + + @available(iOS 15.0, *) + private func makeViewModel( + subscriptions: [SubscriptionTransaction] = [], + entitlements: [Entitlement] = [] + ) async -> CustomerCenterViewModel { + let (deps, _, _) = CustomerCenterDependencies.mock( + info: CustomerInfo(subscriptions: subscriptions, nonSubscriptions: [], entitlements: entitlements) + ) + let viewModel = CustomerCenterViewModel( + configuration: .default, + dependencies: deps, + strings: .english + ) + await viewModel.load() + return viewModel + } + + @available(iOS 15.0, *) + private func hasActions(_ viewModel: CustomerCenterViewModel) throws -> Bool { + let purchase = try #require(viewModel.purchases.first, "the row exists — nothing is hidden") + return viewModel.hasActions(for: purchase) + } + + /// The shapes the last review found opening onto an empty "Actions" section. + @available(iOS 15.0, *) + @Test("a revoked App Store subscription has nothing to act on") + func revokedAppStoreHasNoActions() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(isRevoked: true)]) + #expect(try !hasActions(viewModel)) + } + + @available(iOS 15.0, *) + @Test("a lapsed web subscription has nothing to act on") + func lapsedWebHasNoActions() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe, isActive: false)]) + #expect(try !hasActions(viewModel)) + } + + @available(iOS 15.0, *) + @Test("a purchase from another store has nothing to act on", arguments: [ProductStore.playStore, .other]) + func otherStoreHasNoActions(store: ProductStore) async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: store)]) + #expect(try !hasActions(viewModel)) + } + + @available(iOS 15.0, *) + @Test("a comped grant with no management page has nothing to act on") + func compedGrantHasNoActions() async throws { + let viewModel = await makeViewModel(entitlements: [Entitlement(id: "pro", store: nil)]) + #expect(try !hasActions(viewModel)) + } + + /// And the shapes that do have somewhere to go, so the explanation isn't shown by mistake. + @available(iOS 15.0, *) + @Test("an active App Store subscription has actions") + func activeAppStoreHasActions() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription()]) + #expect(try hasActions(viewModel)) + } + + /// A live web subscription with no management URL still gets the row that explains where the + /// link is — that was fixed once already, and this keeps it from being read as "nothing to do". + @available(iOS 15.0, *) + @Test("an active web subscription has the management row even with no URL") + func activeWebHasActions() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe)]) + #expect(try hasActions(viewModel)) + } +} From c3424db5b5ef258359445db9852909173102a194 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 11 Sep 2026 15:45:30 -0500 Subject: [PATCH 63/64] fix(customer-center): don't tell a paying customer there is nothing to manage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 207663b gave the detail screen an empty state keyed on "no actions resolved". That sentence — "There's nothing to manage for this purchase" — was reachable for a subscription the customer is actively being billed for: a live Play Store subscription on an iOS client drills into a screen whose card reads "Active, renews on …" and whose body says there is nothing to manage. The resolver allowlists App Store and the web stores before it ever checks liveness, so no configuration could change that. The empty state now branches on the purchase, not on emptiness alone. A live, non-revoked subscription from a store this SDK can't drive is told where to manage it — by name for the Play Store, generically for `.other` and `.custom`, whose shared label is "Other" and would have read "manage this subscription through Other" — and only a purchase with genuinely nothing left to do gets the original sentence. That is the `webManageUnavailable` precedent: when there is nowhere to send them, say where to look. The decision lives on the view model (`detailEmptyState(for:)`), in an extension so the main file stays under the length limit, and the tests now assert the reason rather than mere emptiness — the previous `otherStoreHasNoActions` case had pinned the bug. The three strings carry first-draft translations in all 40 non-English locales rather than the English sentence, matching the other 65 keys. Co-Authored-By: Claude Fable 5.1 --- ...stomerCenterViewModel+PurchaseDetail.swift | 47 +++++++++++ .../ViewModel/CustomerCenterViewModel.swift | 12 --- .../Views/CustomerCenterStrings+English.swift | 2 + .../Views/ManagementScreenView.swift | 31 +++++-- .../ar.lproj/Localizable.strings | 4 +- .../ca.lproj/Localizable.strings | 4 +- .../cs.lproj/Localizable.strings | 4 +- .../da.lproj/Localizable.strings | 4 +- .../de.lproj/Localizable.strings | 4 +- .../el.lproj/Localizable.strings | 4 +- .../en.lproj/Localizable.strings | 2 + .../en_AU.lproj/Localizable.strings | 2 + .../en_GB.lproj/Localizable.strings | 2 + .../es.lproj/Localizable.strings | 4 +- .../es_419.lproj/Localizable.strings | 4 +- .../fi.lproj/Localizable.strings | 4 +- .../fr.lproj/Localizable.strings | 4 +- .../fr_CA.lproj/Localizable.strings | 4 +- .../he.lproj/Localizable.strings | 4 +- .../hi.lproj/Localizable.strings | 4 +- .../hr.lproj/Localizable.strings | 4 +- .../hu.lproj/Localizable.strings | 4 +- .../id.lproj/Localizable.strings | 4 +- .../it.lproj/Localizable.strings | 4 +- .../ja.lproj/Localizable.strings | 4 +- .../ko.lproj/Localizable.strings | 4 +- .../ms.lproj/Localizable.strings | 4 +- .../nb.lproj/Localizable.strings | 4 +- .../nl.lproj/Localizable.strings | 4 +- .../nn.lproj/Localizable.strings | 4 +- .../pl.lproj/Localizable.strings | 4 +- .../pt.lproj/Localizable.strings | 4 +- .../pt_BR.lproj/Localizable.strings | 4 +- .../pt_PT.lproj/Localizable.strings | 4 +- .../ro.lproj/Localizable.strings | 4 +- .../ru.lproj/Localizable.strings | 4 +- .../sk.lproj/Localizable.strings | 4 +- .../sl.lproj/Localizable.strings | 4 +- .../sv.lproj/Localizable.strings | 4 +- .../th.lproj/Localizable.strings | 4 +- .../tr.lproj/Localizable.strings | 4 +- .../uk.lproj/Localizable.strings | 4 +- .../vi.lproj/Localizable.strings | 4 +- .../zh_Hans.lproj/Localizable.strings | 4 +- .../zh_Hant.lproj/Localizable.strings | 4 +- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../PurchaseDetailActionsTests.swift | 81 ++++++++++++------- 47 files changed, 250 insertions(+), 85 deletions(-) create mode 100644 Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift new file mode 100644 index 0000000000..8fb6fb1bba --- /dev/null +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift @@ -0,0 +1,47 @@ +// +// CustomerCenterViewModel+PurchaseDetail.swift +// +// +// Created by Jordan Morgan on 11/09/2026. +// + +import Foundation + +// MARK: - What the purchase detail screen shows below the card + +@available(iOS 15.0, *) +extension CustomerCenterViewModel { + /// Why a detail screen has no actions, when it has none. Every subscription row opens its + /// detail screen — that is the rule, and it holds whether or not there is anything left to do — + /// so the screen has to say something true when the resolver comes back empty, and what is + /// true depends on the purchase. + enum DetailEmptyState: Equatable { + /// Nothing is left to do: the subscription is revoked or lapsed, the member isn't the one + /// paying, or a comped grant has no page to send anyone to. + case nothingToDo + /// The customer is still paying for this, from a store this SDK can't drive — the Play Store + /// on an iOS client, or a developer's own system. Telling them there is nothing to manage + /// would be false; telling them where to manage it is the `webManageUnavailable` precedent. + /// `storeLabelKey` names the store when there is a name for it. + case managedElsewhere(storeLabelKey: String?) + } + + /// Whether the detail screen for `purchase` has anything to act on. + func hasActions(for purchase: PurchasePresentation) -> Bool { + !paths(for: purchase, isScreenLevel: false).isEmpty + } + + /// `nil` when the detail screen has actions to show; otherwise which sentence to show instead. + func detailEmptyState(for purchase: PurchasePresentation) -> DetailEmptyState? { + if hasActions(for: purchase) { return nil } + let isLive = purchase.isActive && purchase.badge != .revoked + let isDrivable: Bool = [.appStore, .stripe, .paddle, .superwall].contains(purchase.store) + guard isLive, !isDrivable else { return .nothingToDo } + // Only a store with a real name fills the sentence. `.other` and `.custom` carry the label + // "Other", and "manage this subscription through Other" is worse than the generic line. + switch purchase.store { + case .playStore: return .managedElsewhere(storeLabelKey: purchase.storeLabelKey) + default: return .managedElsewhere(storeLabelKey: nil) + } + } +} diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift index beb4e05cf1..119ed55afb 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift @@ -318,18 +318,6 @@ final class CustomerCenterViewModel: ObservableObject { } await apply(customerInfo: info, refetchProducts: true) } - - /// Whether the detail screen for `purchase` has anything to act on. - /// - /// Every subscription row opens its detail screen; that is the rule, and it holds whether or - /// not there is anything left to do. This decides what the screen shows once it is open: the - /// resolved actions, or a line saying there are none — a revoked App Store subscription, a - /// lapsed web one, a purchase from another store, or a comped grant with no management page - /// all resolve to nothing under the default configuration, and an "Actions" heading over an - /// empty list is worse than saying so. - func hasActions(for purchase: PurchasePresentation) -> Bool { - !paths(for: purchase, isScreenLevel: false).isEmpty - } } // MARK: - Visibility-driven dismissal diff --git a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift index f47cbf8896..e51ca550d7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterStrings+English.swift @@ -51,6 +51,8 @@ let englishStrings: [String: String] = [ "customer_center_path_manage_subscription_web": "Manage subscription", "customer_center_web_manage_unavailable": "Manage your subscription using the link in your emailed receipt.", "customer_center_detail_nothing_to_manage": "There's nothing to manage for this purchase.", + "customer_center_detail_managed_through": "Manage this subscription through %@.", + "customer_center_detail_managed_where_bought": "Manage this subscription where you bought it.", "customer_center_path_refund": "Request a refund", "customer_center_path_change_plan": "Change plan", "customer_center_path_contact_support": "Contact support", diff --git a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift index 044f4819a9..e3a441acf7 100644 --- a/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift +++ b/Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift @@ -75,19 +75,22 @@ struct PurchaseDetailScreenView: View { var body: some View { List { Section { PurchaseCardView(purchase: purchase, refundResult: viewModel.refundResult) } - if viewModel.hasActions(for: purchase) { - Section(strings.string("customer_center_section_actions")) { - PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) - } - } else { - // The row opened this screen regardless — see `hasActions(for:)` — so say what there is - // to say rather than head an empty list with "Actions". + if let empty = viewModel.detailEmptyState(for: purchase) { + // The row opened this screen regardless — see `detailEmptyState(for:)` — so say what + // there is to say rather than head an empty list with "Actions". Which sentence depends + // on the purchase: a subscription the customer is still paying for, from a store this + // SDK can't drive, is told where to manage it; only a purchase with genuinely nothing + // left to do is told that. Section { - Text(strings.string("customer_center_detail_nothing_to_manage")) + Text(emptyStateText(empty)) .font(.subheadline) .foregroundStyle(.secondary) .accessibilityIdentifier("customer_center.detail.nothing_to_manage") } + } else { + Section(strings.string("customer_center_section_actions")) { + PathsListView(viewModel: viewModel, purchase: purchase, isScreenLevel: false) + } } } .listStyle(.insetGrouped) @@ -96,4 +99,16 @@ struct PurchaseDetailScreenView: View { .onAppear { viewModel.surfaceDidAppear() } .onDisappear { viewModel.surfaceDidDisappear() } } + + private func emptyStateText(_ state: CustomerCenterViewModel.DetailEmptyState) -> String { + switch state { + case .nothingToDo: + return strings.string("customer_center_detail_nothing_to_manage") + case .managedElsewhere(let storeLabelKey): + guard let storeLabelKey else { + return strings.string("customer_center_detail_managed_where_bought") + } + return strings.string("customer_center_detail_managed_through", strings.string(storeLabelKey)) + } + } } diff --git a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings index a439d7716b..5d9ee29690 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ar.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "طلب دعم"; "customer_center_support_body" = "يرجى وصف مشكلتك أو سؤالك."; "customer_center_no_mail_app" = "لا يوجد تطبيق بريد مُهيأ على هذا الجهاز. يمكنك التواصل معنا عبر %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "لا يوجد ما يمكن إدارته لعملية الشراء هذه."; +"customer_center_detail_managed_through" = "يمكنك إدارة هذا الاشتراك عبر %@."; +"customer_center_detail_managed_where_bought" = "يمكنك إدارة هذا الاشتراك من حيث اشتريته."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings index 1f1b86a894..666cc85269 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ca.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Sol·licitud d'assistència"; "customer_center_support_body" = "Descriu el teu problema o dubte."; "customer_center_no_mail_app" = "Aquest dispositiu no té cap aplicació de correu configurada. Ens pots contactar a %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "No hi ha res per gestionar en aquesta compra."; +"customer_center_detail_managed_through" = "Gestiona aquesta subscripció a través de %@."; +"customer_center_detail_managed_where_bought" = "Gestiona aquesta subscripció on la vas comprar."; diff --git a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings index e779d0ec57..2d23afe7a1 100644 --- a/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/cs.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Žádost o podporu"; "customer_center_support_body" = "Popište prosím svůj problém nebo dotaz."; "customer_center_no_mail_app" = "V tomto zařízení není nastavena žádná e-mailová aplikace. Můžete nás kontaktovat na %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "U tohoto nákupu není co spravovat."; +"customer_center_detail_managed_through" = "Toto předplatné spravujte přes %@."; +"customer_center_detail_managed_where_bought" = "Toto předplatné spravujte tam, kde jste ho zakoupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings index 81e78acec9..0e5f8ea0ec 100644 --- a/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/da.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Supportanmodning"; "customer_center_support_body" = "Beskriv venligst dit problem eller spørgsmål."; "customer_center_no_mail_app" = "Der er ikke konfigureret en mailapp på denne enhed. Du kan kontakte os på %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Der er intet at administrere for dette køb."; +"customer_center_detail_managed_through" = "Administrer dette abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnement dér, hvor du købte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings index 233aa57bc6..549abf352f 100644 --- a/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/de.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Support-Anfrage"; "customer_center_support_body" = "Bitte beschreiben Sie Ihr Problem oder Ihre Frage."; "customer_center_no_mail_app" = "Auf diesem Gerät ist keine Mail-App eingerichtet. Sie erreichen uns unter %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Für diesen Kauf gibt es nichts zu verwalten."; +"customer_center_detail_managed_through" = "Verwalte dieses Abo über %@."; +"customer_center_detail_managed_where_bought" = "Verwalte dieses Abo dort, wo du es gekauft hast."; diff --git a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings index a71741ecf4..667559515e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/el.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Αίτημα υποστήριξης"; "customer_center_support_body" = "Περιγράψτε το πρόβλημα ή την ερώτησή σας."; "customer_center_no_mail_app" = "Δεν έχει ρυθμιστεί εφαρμογή αλληλογραφίας σε αυτή τη συσκευή. Μπορείτε να επικοινωνήσετε μαζί μας στο %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Δεν υπάρχει κάτι προς διαχείριση για αυτήν την αγορά."; +"customer_center_detail_managed_through" = "Διαχειριστείτε αυτή τη συνδρομή μέσω %@."; +"customer_center_detail_managed_where_bought" = "Διαχειριστείτε αυτή τη συνδρομή εκεί όπου την αγοράσατε."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings index 3670e26068..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en.lproj/Localizable.strings @@ -126,3 +126,5 @@ "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; "customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings index 3670e26068..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_AU.lproj/Localizable.strings @@ -126,3 +126,5 @@ "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; "customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings index 3670e26068..2242c92551 100644 --- a/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/en_GB.lproj/Localizable.strings @@ -126,3 +126,5 @@ "customer_center_support_body" = "Please describe your issue or question."; "customer_center_no_mail_app" = "No mail app is configured on this device. You can reach us at %@."; "customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_managed_through" = "Manage this subscription through %@."; +"customer_center_detail_managed_where_bought" = "Manage this subscription where you bought it."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings index ef6d71ecbe..c49891242b 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Solicitud de soporte"; "customer_center_support_body" = "Describa su problema o pregunta."; "customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puede contactarnos en %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "No hay nada que gestionar en esta compra."; +"customer_center_detail_managed_through" = "Gestiona esta suscripción a través de %@."; +"customer_center_detail_managed_where_bought" = "Gestiona esta suscripción donde la compraste."; diff --git a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings index b3d1da552b..329b9a4ed4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/es_419.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Solicitud de soporte"; "customer_center_support_body" = "Describe tu problema o pregunta."; "customer_center_no_mail_app" = "Este dispositivo no tiene ninguna aplicación de correo configurada. Puedes contactarnos en %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "No hay nada que administrar en esta compra."; +"customer_center_detail_managed_through" = "Administra esta suscripción a través de %@."; +"customer_center_detail_managed_where_bought" = "Administra esta suscripción donde la compraste."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings index 9ce1f8b86a..9b24d3d4e4 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fi.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Tukipyyntö"; "customer_center_support_body" = "Kuvaile ongelmaasi tai kysymystäsi."; "customer_center_no_mail_app" = "Tähän laitteeseen ei ole määritetty sähköpostisovellusta. Voit tavoittaa meidät osoitteessa %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Tässä ostoksessa ei ole mitään hallittavaa."; +"customer_center_detail_managed_through" = "Hallitse tätä tilausta palvelussa %@."; +"customer_center_detail_managed_where_bought" = "Hallitse tätä tilausta siellä, mistä ostit sen."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings index 467f92cb86..4b4960bd69 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Demande d'assistance"; "customer_center_support_body" = "Merci de décrire votre problème ou votre question."; "customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Il n'y a rien à gérer pour cet achat."; +"customer_center_detail_managed_through" = "Gérez cet abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Gérez cet abonnement là où vous l'avez acheté."; diff --git a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings index b9db1093eb..a7813db734 100644 --- a/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/fr_CA.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Demande d'assistance"; "customer_center_support_body" = "Merci de décrire votre problème ou votre question."; "customer_center_no_mail_app" = "Aucune application de messagerie n'est configurée sur cet appareil. Vous pouvez nous contacter à l'adresse %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Il n'y a rien à gérer pour cet achat."; +"customer_center_detail_managed_through" = "Gérez cet abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Gérez cet abonnement là où vous l'avez acheté."; diff --git a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings index 4df13b7dd9..6cb2cd02d6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/he.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "בקשת תמיכה"; "customer_center_support_body" = "אנא תאר את הבעיה או השאלה שלך."; "customer_center_no_mail_app" = "לא הוגדרה אפליקציית אימייל במכשיר זה. תוכל ליצור איתנו קשר בכתובת %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "אין מה לנהל ברכישה זו."; +"customer_center_detail_managed_through" = "נהלו את המינוי הזה דרך %@."; +"customer_center_detail_managed_where_bought" = "נהלו את המינוי הזה במקום שבו רכשתם אותו."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings index e34933aaa5..45215e8576 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hi.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "सहायता अनुरोध"; "customer_center_support_body" = "कृपया अपनी समस्या या प्रश्न का वर्णन करें।"; "customer_center_no_mail_app" = "इस डिवाइस पर कोई मेल ऐप कॉन्फ़िगर नहीं है। आप हमसे %@ पर संपर्क कर सकते हैं।"; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "इस खरीद के लिए प्रबंधित करने के लिए कुछ नहीं है।"; +"customer_center_detail_managed_through" = "इस सदस्यता को %@ के माध्यम से प्रबंधित करें।"; +"customer_center_detail_managed_where_bought" = "इस सदस्यता को वहीं प्रबंधित करें जहाँ से आपने इसे खरीदा था।"; diff --git a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings index 133c0b45ec..6b603a99aa 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hr.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Zahtjev za podršku"; "customer_center_support_body" = "Opišite svoj problem ili pitanje."; "customer_center_no_mail_app" = "Na ovom uređaju nije postavljena aplikacija za e-poštu. Možete nas kontaktirati na %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Za ovu kupnju nema ničega za upravljanje."; +"customer_center_detail_managed_through" = "Upravljajte ovom pretplatom putem %@."; +"customer_center_detail_managed_where_bought" = "Upravljajte ovom pretplatom tamo gdje ste je kupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings index f9b1ff7526..6afef55a14 100644 --- a/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/hu.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Támogatási kérelem"; "customer_center_support_body" = "Kérjük, írja le a problémáját vagy kérdését."; "customer_center_no_mail_app" = "Ezen az eszközön nincs beállítva levelezőalkalmazás. Elérhet minket a következő címen: %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Ehhez a vásárláshoz nincs mit kezelni."; +"customer_center_detail_managed_through" = "Kezeld ezt az előfizetést itt: %@."; +"customer_center_detail_managed_where_bought" = "Kezeld ezt az előfizetést ott, ahol megvásároltad."; diff --git a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings index c1fe57e1c2..c5e24f7e68 100644 --- a/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/id.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Permintaan dukungan"; "customer_center_support_body" = "Silakan jelaskan masalah atau pertanyaan Anda."; "customer_center_no_mail_app" = "Tidak ada aplikasi email yang dikonfigurasi di perangkat ini. Anda dapat menghubungi kami di %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Tidak ada yang perlu dikelola untuk pembelian ini."; +"customer_center_detail_managed_through" = "Kelola langganan ini melalui %@."; +"customer_center_detail_managed_where_bought" = "Kelola langganan ini di tempat Anda membelinya."; diff --git a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings index a91df5bc1d..1d13b652cf 100644 --- a/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/it.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Richiesta di assistenza"; "customer_center_support_body" = "Descrivi il tuo problema o la tua domanda."; "customer_center_no_mail_app" = "Su questo dispositivo non è configurata alcuna app di posta. Puoi contattarci all'indirizzo %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Non c'è nulla da gestire per questo acquisto."; +"customer_center_detail_managed_through" = "Gestisci questo abbonamento tramite %@."; +"customer_center_detail_managed_where_bought" = "Gestisci questo abbonamento dove l'hai acquistato."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings index d1dfa5aefb..94f0748671 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ja.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "サポートリクエスト"; "customer_center_support_body" = "問題やご質問の内容をご記入ください。"; "customer_center_no_mail_app" = "このデバイスにはメールアプリが設定されていません。%@までご連絡ください。"; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "この購入には管理できる項目がありません。"; +"customer_center_detail_managed_through" = "このサブスクリプションは%@で管理してください。"; +"customer_center_detail_managed_where_bought" = "このサブスクリプションは購入元で管理してください。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings index ba61d8f5db..f12dba15bb 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ko.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "지원 요청"; "customer_center_support_body" = "문제나 질문을 설명해 주세요."; "customer_center_no_mail_app" = "이 기기에 메일 앱이 설정되어 있지 않습니다. %@로 문의해 주세요."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "이 구매에는 관리할 항목이 없습니다."; +"customer_center_detail_managed_through" = "이 구독은 %@에서 관리하세요."; +"customer_center_detail_managed_where_bought" = "이 구독은 구매한 곳에서 관리하세요."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings index d550447f8c..35ea16741a 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ms.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Permohonan sokongan"; "customer_center_support_body" = "Sila terangkan masalah atau soalan anda."; "customer_center_no_mail_app" = "Tiada aplikasi mel dikonfigurasikan pada peranti ini. Anda boleh menghubungi kami di %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Tiada apa-apa untuk diurus bagi pembelian ini."; +"customer_center_detail_managed_through" = "Urus langganan ini melalui %@."; +"customer_center_detail_managed_where_bought" = "Urus langganan ini di tempat anda membelinya."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings index b9fde6ede3..4027de83f9 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nb.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Support-forespørsel"; "customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; "customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Det er ingenting å administrere for dette kjøpet."; +"customer_center_detail_managed_through" = "Administrer dette abonnementet via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnementet der du kjøpte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings index e581e54f94..abedd588f6 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nl.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Ondersteuningsverzoek"; "customer_center_support_body" = "Beschrijf uw probleem of vraag."; "customer_center_no_mail_app" = "Er is geen mail-app geconfigureerd op dit apparaat. U kunt ons bereiken via %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Er valt niets te beheren voor deze aankoop."; +"customer_center_detail_managed_through" = "Beheer dit abonnement via %@."; +"customer_center_detail_managed_where_bought" = "Beheer dit abonnement waar je het hebt gekocht."; diff --git a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings index 3404c2a4d8..e1618fb05e 100644 --- a/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/nn.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Support-forespørsel"; "customer_center_support_body" = "Beskriv problemet eller spørsmålet ditt."; "customer_center_no_mail_app" = "Det er ikke satt opp noen e-postapp på denne enheten. Du kan nå oss på %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Det er ingenting å administrere for dette kjøpet."; +"customer_center_detail_managed_through" = "Administrer dette abonnementet via %@."; +"customer_center_detail_managed_where_bought" = "Administrer dette abonnementet der du kjøpte det."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings index 1454711fc0..bec4e5fc43 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pl.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Zgłoszenie do pomocy technicznej"; "customer_center_support_body" = "Opisz swój problem lub pytanie."; "customer_center_no_mail_app" = "Na tym urządzeniu nie skonfigurowano aplikacji pocztowej. Możesz się z nami skontaktować pod adresem %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Nie ma nic do zarządzania w tym zakupie."; +"customer_center_detail_managed_through" = "Zarządzaj tą subskrypcją przez %@."; +"customer_center_detail_managed_where_bought" = "Zarządzaj tą subskrypcją tam, gdzie ją kupiono."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings index d156f783ab..357c70b2ca 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerir nesta compra."; +"customer_center_detail_managed_through" = "Gira esta subscrição através de %@."; +"customer_center_detail_managed_where_bought" = "Gira esta subscrição onde a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings index 50fc0bebbe..cdfd168977 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_BR.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerenciar nesta compra."; +"customer_center_detail_managed_through" = "Gerencie esta assinatura pelo %@."; +"customer_center_detail_managed_where_bought" = "Gerencie esta assinatura onde você a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings index 4a33bc3ea0..61ceba3aab 100644 --- a/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/pt_PT.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Pedido de suporte"; "customer_center_support_body" = "Descreva o seu problema ou questão."; "customer_center_no_mail_app" = "Não existe nenhuma aplicação de correio configurada neste dispositivo. Pode contactar-nos em %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Não há nada para gerir nesta compra."; +"customer_center_detail_managed_through" = "Gira esta subscrição através de %@."; +"customer_center_detail_managed_where_bought" = "Gira esta subscrição onde a comprou."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings index 7530680cdc..2894a81ee2 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ro.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Solicitare de asistență"; "customer_center_support_body" = "Vă rugăm să descrieți problema sau întrebarea dvs."; "customer_center_no_mail_app" = "Nu este configurată nicio aplicație de e-mail pe acest dispozitiv. Ne puteți contacta la %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Nu există nimic de gestionat pentru această achiziție."; +"customer_center_detail_managed_through" = "Gestionează acest abonament prin %@."; +"customer_center_detail_managed_where_bought" = "Gestionează acest abonament acolo unde l-ai cumpărat."; diff --git a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings index 184f5334fd..74c378c188 100644 --- a/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/ru.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Запрос в поддержку"; "customer_center_support_body" = "Пожалуйста, опишите вашу проблему или вопрос."; "customer_center_no_mail_app" = "На этом устройстве не настроено почтовое приложение. Вы можете связаться с нами по адресу %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Для этой покупки нечего настраивать."; +"customer_center_detail_managed_through" = "Управляйте этой подпиской через %@."; +"customer_center_detail_managed_where_bought" = "Управляйте этой подпиской там, где вы её оформили."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings index 2e8e20c4d0..b6632d2167 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sk.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Žiadosť o podporu"; "customer_center_support_body" = "Opíšte, prosím, váš problém alebo otázku."; "customer_center_no_mail_app" = "V tomto zariadení nie je nastavená žiadna e-mailová aplikácia. Môžete nás kontaktovať na %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Pri tomto nákupe nie je čo spravovať."; +"customer_center_detail_managed_through" = "Toto predplatné spravujte cez %@."; +"customer_center_detail_managed_where_bought" = "Toto predplatné spravujte tam, kde ste ho kúpili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings index e5e415a178..8dfe573c91 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sl.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Zahteva za podporo"; "customer_center_support_body" = "Opišite svojo težavo ali vprašanje."; "customer_center_no_mail_app" = "V tej napravi ni nastavljena aplikacija za e-pošto. Lahko nas kontaktirate na %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Za ta nakup ni ničesar za upravljanje."; +"customer_center_detail_managed_through" = "To naročnino upravljajte prek %@."; +"customer_center_detail_managed_where_bought" = "To naročnino upravljajte tam, kjer ste jo kupili."; diff --git a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings index ed6eac3a66..e6788e6258 100644 --- a/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/sv.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Supportförfrågan"; "customer_center_support_body" = "Beskriv ditt problem eller din fråga."; "customer_center_no_mail_app" = "Ingen e-postapp är konfigurerad på den här enheten. Du kan nå oss på %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Det finns inget att hantera för det här köpet."; +"customer_center_detail_managed_through" = "Hantera den här prenumerationen via %@."; +"customer_center_detail_managed_where_bought" = "Hantera den här prenumerationen där du köpte den."; diff --git a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings index e09041050b..c95da90919 100644 --- a/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/th.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "คำขอการสนับสนุน"; "customer_center_support_body" = "โปรดอธิบายปัญหาหรือคำถามของคุณ"; "customer_center_no_mail_app" = "ไม่มีแอปอีเมลที่ตั้งค่าไว้บนอุปกรณ์นี้ คุณสามารถติดต่อเราได้ที่ %@"; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "ไม่มีสิ่งที่ต้องจัดการสำหรับการซื้อนี้"; +"customer_center_detail_managed_through" = "จัดการการสมัครสมาชิกนี้ผ่าน %@"; +"customer_center_detail_managed_where_bought" = "จัดการการสมัครสมาชิกนี้จากที่ที่คุณซื้อ"; diff --git a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings index 8db8d69f8b..c028470270 100644 --- a/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/tr.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Destek talebi"; "customer_center_support_body" = "Lütfen sorununuzu veya sorunuzu açıklayın."; "customer_center_no_mail_app" = "Bu cihazda yapılandırılmış bir posta uygulaması yok. Bize %@ adresinden ulaşabilirsiniz."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Bu satın alma için yönetilecek bir şey yok."; +"customer_center_detail_managed_through" = "Bu aboneliği %@ üzerinden yönetin."; +"customer_center_detail_managed_where_bought" = "Bu aboneliği satın aldığınız yerden yönetin."; diff --git a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings index 613f06d995..712e3c7856 100644 --- a/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/uk.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Запит до підтримки"; "customer_center_support_body" = "Будь ласка, опишіть вашу проблему або запитання."; "customer_center_no_mail_app" = "На цьому пристрої не налаштовано жодного поштового застосунку. Ви можете зв'язатися з нами за адресою %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Для цієї покупки немає чим керувати."; +"customer_center_detail_managed_through" = "Керуйте цією підпискою через %@."; +"customer_center_detail_managed_where_bought" = "Керуйте цією підпискою там, де ви її оформили."; diff --git a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings index a37ff4923c..1a6ce050fc 100644 --- a/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/vi.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "Yêu cầu hỗ trợ"; "customer_center_support_body" = "Vui lòng mô tả vấn đề hoặc câu hỏi của bạn."; "customer_center_no_mail_app" = "Không có ứng dụng thư nào được định cấu hình trên thiết bị này. Bạn có thể liên hệ với chúng tôi tại %@."; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "Không có gì để quản lý cho giao dịch mua này."; +"customer_center_detail_managed_through" = "Quản lý gói đăng ký này qua %@."; +"customer_center_detail_managed_where_bought" = "Quản lý gói đăng ký này tại nơi bạn đã mua."; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings index 9493ff2f11..f2df86fc31 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hans.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "支持请求"; "customer_center_support_body" = "请描述您的问题或疑问。"; "customer_center_no_mail_app" = "此设备未配置邮件应用。您可以通过 %@ 联系我们。"; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "此购买项目没有可管理的内容。"; +"customer_center_detail_managed_through" = "请通过%@管理此订阅。"; +"customer_center_detail_managed_where_bought" = "请在购买处管理此订阅。"; diff --git a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings index 14f817b9b7..84c4557042 100644 --- a/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings +++ b/Sources/SuperwallKit/Resources/Localizations/zh_Hant.lproj/Localizable.strings @@ -125,4 +125,6 @@ "customer_center_support_subject" = "支援請求"; "customer_center_support_body" = "請描述您的問題或疑問。"; "customer_center_no_mail_app" = "此裝置未設定郵件應用程式。您可以透過 %@ 與我們聯絡。"; -"customer_center_detail_nothing_to_manage" = "There's nothing to manage for this purchase."; +"customer_center_detail_nothing_to_manage" = "此購買項目沒有可管理的內容。"; +"customer_center_detail_managed_through" = "請透過%@管理此訂閱。"; +"customer_center_detail_managed_where_bought" = "請在購買處管理此訂閱。"; diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index ab1a06bdaf..a2d4a972c9 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -417,6 +417,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 */; }; + B02D152D88E005E08078DA51 /* CustomerCenterViewModel+PurchaseDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */; }; B03C4840E7E3DEAE814B374E /* CustomerCenterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D9EB8C0E80BF38D3E75F23D /* CustomerCenterAction.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; @@ -1112,6 +1113,7 @@ B00929DACD8621FC32F83927 /* SK2StoreProductCyclesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SK2StoreProductCyclesTests.swift; sourceTree = ""; }; B0E0D63E991FE00B6C172F83 /* ManagementScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagementScreenView.swift; sourceTree = ""; }; B0E817399EBAAB14C51A1DCB /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; + B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerCenterViewModel+PurchaseDetail.swift"; sourceTree = ""; }; B17DB6AB272712E9350966E4 /* TestModeManagerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerFactory.swift; sourceTree = ""; }; B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallOptions.swift; sourceTree = ""; }; B1DF64C51FA26B40D8D7B880 /* ScaleAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScaleAnimation.swift; sourceTree = ""; }; @@ -2349,6 +2351,7 @@ children = ( 91BC4FDC29B7919F3C976C14 /* CustomerCenterDependencies.swift */, 16C0D857F4714F3B76D58D9F /* CustomerCenterViewModel.swift */, + B167F35F1B87119D05686C7B /* CustomerCenterViewModel+PurchaseDetail.swift */, 7D672DB1BB0AFC32C1DA3148 /* CustomerCenterViewModel+Support.swift */, 96A6D71D51DB3F1BCE3E167B /* CustomerCenterViewModel+UpdateBanner.swift */, ); @@ -3827,6 +3830,7 @@ 54BF320BC284406282CB49B6 /* CustomerCenterStrings+English.swift in Sources */, FACCB02103E21B86A98E12BE /* CustomerCenterView.swift in Sources */, 8901727EE5E2048125791BAB /* CustomerCenterViewController.swift in Sources */, + B02D152D88E005E08078DA51 /* CustomerCenterViewModel+PurchaseDetail.swift in Sources */, E38FE7475E82AC6EA10F2C65 /* CustomerCenterViewModel+Support.swift in Sources */, 7C56FE8873F3E1E57905BC91 /* CustomerCenterViewModel+UpdateBanner.swift in Sources */, 46E56EAC8F9CEB8F567C5BCA /* CustomerCenterViewModel.swift in Sources */, diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift index 5f52065a7e..b37224721e 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift @@ -9,13 +9,17 @@ import Testing import Foundation @testable import SuperwallKit -/// Every subscription row opens its detail screen. These pin what that screen has to say once -/// it is open, driven from the shipped `.default` configuration — no support email, no web +/// Every subscription row opens its detail screen. These pin what that screen has to say once it +/// is open, driven from the shipped `.default` configuration — no support email, no web /// management URL — because that is the configuration under which several realistic purchases -/// resolve to no actions at all, and a heading over an empty list is not an acceptable answer. -@Suite("Purchase detail: actions or an explanation") +/// resolve to no actions at all. What the screen says then depends on *why* there are none: a +/// customer still paying for a subscription this SDK can't drive must be told where to manage +/// it, never that there is nothing to manage. +@Suite("Purchase detail: actions, or the right explanation") @MainActor struct PurchaseDetailActionsTests { + typealias EmptyState = CustomerCenterViewModel.DetailEmptyState + private func subscription( store: ProductStore = .appStore, isActive: Bool = true, @@ -54,54 +58,77 @@ struct PurchaseDetailActionsTests { } @available(iOS 15.0, *) - private func hasActions(_ viewModel: CustomerCenterViewModel) throws -> Bool { + private func emptyState(_ viewModel: CustomerCenterViewModel) throws -> EmptyState? { let purchase = try #require(viewModel.purchases.first, "the row exists — nothing is hidden") - return viewModel.hasActions(for: purchase) + return viewModel.detailEmptyState(for: purchase) } - /// The shapes the last review found opening onto an empty "Actions" section. + // MARK: Genuinely nothing to do + @available(iOS 15.0, *) - @Test("a revoked App Store subscription has nothing to act on") - func revokedAppStoreHasNoActions() async throws { + @Test("a revoked App Store subscription: nothing to do") + func revokedAppStore() async throws { let viewModel = await makeViewModel(subscriptions: [subscription(isRevoked: true)]) - #expect(try !hasActions(viewModel)) + #expect(try emptyState(viewModel) == .nothingToDo) } @available(iOS 15.0, *) - @Test("a lapsed web subscription has nothing to act on") - func lapsedWebHasNoActions() async throws { + @Test("a lapsed web subscription: nothing to do") + func lapsedWeb() async throws { let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe, isActive: false)]) - #expect(try !hasActions(viewModel)) + #expect(try emptyState(viewModel) == .nothingToDo) } @available(iOS 15.0, *) - @Test("a purchase from another store has nothing to act on", arguments: [ProductStore.playStore, .other]) - func otherStoreHasNoActions(store: ProductStore) async throws { - let viewModel = await makeViewModel(subscriptions: [subscription(store: store)]) - #expect(try !hasActions(viewModel)) + @Test("a comped grant with no management page: nothing to do") + func compedGrant() async throws { + let viewModel = await makeViewModel(entitlements: [Entitlement(id: "pro", store: nil)]) + #expect(try emptyState(viewModel) == .nothingToDo) } + // MARK: Still paying, but not here + + /// The shape the last review caught: an active Play Store subscription on an iOS client. Its + /// card reads "Active — renews on …", so "nothing to manage" would be a lie. The store has a + /// name, and the sentence uses it. @available(iOS 15.0, *) - @Test("a comped grant with no management page has nothing to act on") - func compedGrantHasNoActions() async throws { - let viewModel = await makeViewModel(entitlements: [Entitlement(id: "pro", store: nil)]) - #expect(try !hasActions(viewModel)) + @Test("a live Play Store subscription is managed elsewhere, by name") + func livePlayStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .playStore)]) + #expect(try emptyState(viewModel) == .managedElsewhere(storeLabelKey: "customer_center_store_google_play")) + } + + @available(iOS 15.0, *) + @Test("a live subscription from an unnamed store is managed elsewhere, generically") + func liveOtherStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .other)]) + #expect(try emptyState(viewModel) == .managedElsewhere(storeLabelKey: nil)) + } + + /// A lapsed Play Store subscription is not "managed elsewhere" — there is nothing left to + /// manage anywhere. Liveness, not store, decides between the two sentences. + @available(iOS 15.0, *) + @Test("a lapsed Play Store subscription: nothing to do") + func lapsedPlayStore() async throws { + let viewModel = await makeViewModel(subscriptions: [subscription(store: .playStore, isActive: false)]) + #expect(try emptyState(viewModel) == .nothingToDo) } - /// And the shapes that do have somewhere to go, so the explanation isn't shown by mistake. + // MARK: Has actions, so no explanation at all + @available(iOS 15.0, *) @Test("an active App Store subscription has actions") - func activeAppStoreHasActions() async throws { + func activeAppStore() async throws { let viewModel = await makeViewModel(subscriptions: [subscription()]) - #expect(try hasActions(viewModel)) + #expect(try emptyState(viewModel) == nil) } /// A live web subscription with no management URL still gets the row that explains where the - /// link is — that was fixed once already, and this keeps it from being read as "nothing to do". + /// link is — fixed once already, and this keeps it from being read as an empty state. @available(iOS 15.0, *) @Test("an active web subscription has the management row even with no URL") - func activeWebHasActions() async throws { + func activeWeb() async throws { let viewModel = await makeViewModel(subscriptions: [subscription(store: .stripe)]) - #expect(try hasActions(viewModel)) + #expect(try emptyState(viewModel) == nil) } } From 80b1535428be55e08c699ee087b0542692d86b00 Mon Sep 17 00:00:00 2001 From: Jordan Morgan Date: Fri, 11 Sep 2026 16:02:30 -0500 Subject: [PATCH 64/64] fix(customer-center): a lifetime grant is not a subscription to manage elsewhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c3424db routed every live, non-revoked purchase from a store this SDK can't drive to "manage this subscription through …". A lifetime entitlement is live and never revoked, so a lifetime Play Store unlock viewed on iOS read "Lifetime" on the card and "Manage this subscription through Google Play" beneath it — pointing at a renewal that doesn't exist. Nothing renews, so there is nothing to manage anywhere: the lifetime badge now lands on the original sentence, the same guard the resolver already applies to change-plan. Test pins the shape by badge before asserting the outcome. Co-Authored-By: Claude Fable 5.1 --- .../CustomerCenterViewModel+PurchaseDetail.swift | 5 ++++- .../ViewModel/PurchaseDetailActionsTests.swift | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift index 8fb6fb1bba..952d4fe736 100644 --- a/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift +++ b/Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel+PurchaseDetail.swift @@ -34,7 +34,10 @@ extension CustomerCenterViewModel { /// `nil` when the detail screen has actions to show; otherwise which sentence to show instead. func detailEmptyState(for purchase: PurchasePresentation) -> DetailEmptyState? { if hasActions(for: purchase) { return nil } - let isLive = purchase.isActive && purchase.badge != .revoked + // A lifetime grant is excluded on purpose: nothing renews, so there is nothing to manage + // anywhere — the same guard the resolver applies to change-plan. Sending its owner to the + // Play Store to "manage this subscription" would point at a renewal that doesn't exist. + let isLive = purchase.isActive && purchase.badge != .revoked && purchase.badge != .lifetime let isDrivable: Bool = [.appStore, .stripe, .paddle, .superwall].contains(purchase.store) guard isLive, !isDrivable else { return .nothingToDo } // Only a store with a real name fills the sentence. `.other` and `.custom` carry the label diff --git a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift index b37224721e..27ca71b6bb 100644 --- a/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift +++ b/Tests/SuperwallKitTests/CustomerCenter/ViewModel/PurchaseDetailActionsTests.swift @@ -86,6 +86,19 @@ struct PurchaseDetailActionsTests { #expect(try emptyState(viewModel) == .nothingToDo) } + /// A lifetime grant from a store this SDK can't drive is not "managed elsewhere": nothing + /// renews, so there is nothing to manage anywhere, and the card's "Lifetime" badge would sit + /// over a sentence about a subscription. Liveness alone isn't the test; renewal is. + @available(iOS 15.0, *) + @Test("a lifetime Play Store grant: nothing to do, not a subscription to manage elsewhere") + func lifetimePlayStoreGrant() async throws { + let lifetime = Entitlement(id: "pro", isActive: true, store: .playStore, isLifetime: true) + let viewModel = await makeViewModel(entitlements: [lifetime]) + let purchase = try #require(viewModel.purchases.first) + #expect(purchase.badge == .lifetime, "the shape under test") + #expect(viewModel.detailEmptyState(for: purchase) == .nothingToDo) + } + // MARK: Still paying, but not here /// The shape the last review caught: an active Play Store subscription on an iOS client. Its