diff --git a/RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift b/RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift index a9e4f42513..42785c8403 100644 --- a/RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift +++ b/RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift @@ -30,21 +30,24 @@ struct ProcessedLocalizedConfiguration: PaywallLocalizedConfiguration { init( _ configuration: PaywallData.LocalizedConfiguration, _ dataProvider: VariableDataProvider, + _ context: VariableHandler.Context, _ locale: Locale ) { self.init( - title: configuration.title.processed(with: dataProvider, locale: locale), - subtitle: configuration.subtitle?.processed(with: dataProvider, locale: locale), - callToAction: configuration.callToAction.processed(with: dataProvider, locale: locale), + title: configuration.title.processed(with: dataProvider, context: context, locale: locale), + subtitle: configuration.subtitle?.processed(with: dataProvider, context: context, locale: locale), + callToAction: configuration.callToAction.processed(with: dataProvider, context: context, locale: locale), callToActionWithIntroOffer: configuration.callToActionWithIntroOffer?.processed(with: dataProvider, + context: context, locale: locale), - offerDetails: configuration.offerDetails?.processed(with: dataProvider, locale: locale), + offerDetails: configuration.offerDetails?.processed(with: dataProvider, context: context, locale: locale), offerDetailsWithIntroOffer: configuration.offerDetailsWithIntroOffer?.processed(with: dataProvider, + context: context, locale: locale), - offerName: configuration.offerName?.processed(with: dataProvider, locale: locale), + offerName: configuration.offerName?.processed(with: dataProvider, context: context, locale: locale), features: configuration.features.map { - .init(title: $0.title.processed(with: dataProvider, locale: locale), - content: $0.content?.processed(with: dataProvider, locale: locale), + .init(title: $0.title.processed(with: dataProvider, context: context, locale: locale), + content: $0.content?.processed(with: dataProvider, context: context, locale: locale), iconID: $0.iconID) } ) diff --git a/RevenueCatUI/Data/TemplateViewConfiguration.swift b/RevenueCatUI/Data/TemplateViewConfiguration.swift index b0e507960c..78dffd2e70 100644 --- a/RevenueCatUI/Data/TemplateViewConfiguration.swift +++ b/RevenueCatUI/Data/TemplateViewConfiguration.swift @@ -121,18 +121,24 @@ extension TemplateViewConfiguration.PackageConfiguration { let filtered = TemplateViewConfiguration.filter(packages: packages, with: filter) let mostExpensivePricePerMonth = Self.mostExpensivePricePerMonth(in: filtered) - let filteredPackages = filtered + let filteredPackages: [TemplateViewConfiguration.Package] = filtered .map { package in - TemplateViewConfiguration.Package( + let discount = Self.discount( + from: package.storeProduct.pricePerMonth?.doubleValue, + relativeTo: mostExpensivePricePerMonth + ) + + return .init( content: package, - localization: localization.processVariables(with: package, locale: locale), + localization: localization.processVariables( + with: package, + context: .init(discountRelativeToMostExpensivePerMonth: discount), + locale: locale + ), currentlySubscribed: activelySubscribedProductIdentifiers.contains( package.storeProduct.productIdentifier ), - discountRelativeToMostExpensivePerMonth: Self.discount( - from: package.storeProduct.pricePerMonth?.doubleValue, - relativeTo: mostExpensivePricePerMonth - ) + discountRelativeToMostExpensivePerMonth: discount ) } diff --git a/RevenueCatUI/Data/Variables.swift b/RevenueCatUI/Data/Variables.swift index 7a0a4a56c3..fffc823a63 100644 --- a/RevenueCatUI/Data/Variables.swift +++ b/RevenueCatUI/Data/Variables.swift @@ -16,8 +16,12 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension PaywallData.LocalizedConfiguration { - func processVariables(with package: Package, locale: Locale = .current) -> ProcessedLocalizedConfiguration { - return .init(self, package, locale) + func processVariables( + with package: Package, + context: VariableHandler.Context, + locale: Locale = .current + ) -> ProcessedLocalizedConfiguration { + return .init(self, package, context, locale) } } @@ -38,6 +42,7 @@ protocol VariableDataProvider { func localizedPricePerPeriod(_ locale: Locale) -> String func localizedPriceAndPerMonth(_ locale: Locale) -> String + func localizedRelativeDiscount(_ discount: Double?, _ locale: Locale) -> String? } @@ -45,15 +50,29 @@ protocol VariableDataProvider { @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) enum VariableHandler { + /// Information necessary for computing variables + struct Context { + + var discountRelativeToMostExpensivePerMonth: Double? + + } + static func processVariables( in string: String, with provider: VariableDataProvider, + context: Context, locale: Locale = .current ) -> String { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { - return VariableHandlerIOS16.processVariables(in: string, with: provider, locale: locale) + return VariableHandlerIOS16.processVariables(in: string, + with: provider, + context: context, + locale: locale) } else { - return VariableHandlerIOS15.processVariables(in: string, with: provider, locale: locale) + return VariableHandlerIOS15.processVariables(in: string, + with: provider, + context: context, + locale: locale) } } @@ -65,21 +84,25 @@ enum VariableHandler { ) } - fileprivate typealias ValueProvider = (VariableDataProvider, Locale) -> String? + fileprivate typealias ValueProvider = (VariableDataProvider, + VariableHandler.Context, + Locale) -> String? // swiftlint:disable:next cyclomatic_complexity fileprivate static func provider(for variableName: String) -> ValueProvider? { switch variableName { - case "app_name": return { (provider, _) in provider.applicationName } - case "price": return { (provider, _) in provider.localizedPrice } - case "price_per_period": return { $0.localizedPricePerPeriod($1) } - case "total_price_and_per_month": return { $0.localizedPriceAndPerMonth($1) } - case "product_name": return { (provider, _) in provider.productName } - case "sub_period": return { $0.periodName($1) } - case "sub_price_per_month": return { (provider, _) in provider.localizedPricePerMonth } - case "sub_duration": return { $0.subscriptionDuration($1) } - case "sub_offer_duration": return { $0.introductoryOfferDuration($1) } - case "sub_offer_price": return { (provider, _) in provider.localizedIntroductoryOfferPrice } + case "app_name": return { (provider, _, _) in provider.applicationName } + case "price": return { (provider, _, _) in provider.localizedPrice } + case "price_per_period": return { (provider, _, locale) in provider.localizedPricePerPeriod(locale) } + case "total_price_and_per_month": return { (provider, _, locale) in provider.localizedPriceAndPerMonth(locale) } + case "product_name": return { (provider, _, _) in provider.productName } + case "sub_period": return { (provider, _, locale) in provider.periodName(locale) } + case "sub_price_per_month": return { (provider, _, _) in provider.localizedPricePerMonth } + case "sub_duration": return { (provider, _, locale) in provider.subscriptionDuration(locale) } + case "sub_offer_duration": return { (provider, _, locale) in provider.introductoryOfferDuration(locale) } + case "sub_offer_price": return { (provider, _, _) in provider.localizedIntroductoryOfferPrice } + case "sub_relative_discount": return { $0.localizedRelativeDiscount($1.discountRelativeToMostExpensivePerMonth, + $2) } default: Logger.warning(Strings.unrecognized_variable_name(variableName: variableName)) @@ -92,8 +115,12 @@ enum VariableHandler { @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension String { - func processed(with provider: VariableDataProvider, locale: Locale) -> Self { - return VariableHandler.processVariables(in: self, with: provider, locale: locale) + func processed( + with provider: VariableDataProvider, + context: VariableHandler.Context, + locale: Locale + ) -> Self { + return VariableHandler.processVariables(in: self, with: provider, context: context, locale: locale) } func unrecognizedVariables() -> Set { @@ -109,8 +136,12 @@ extension String { @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) private extension VariableDataProvider { - func value(for variableName: String, locale: Locale) -> String { - VariableHandler.provider(for: variableName)?(self, locale) ?? "" + func value( + for variableName: String, + context: VariableHandler.Context, + locale: Locale + ) -> String { + VariableHandler.provider(for: variableName)?(self, context, locale) ?? "" } } @@ -123,13 +154,16 @@ private enum VariableHandlerIOS16 { static func processVariables( in string: String, with provider: VariableDataProvider, + context: VariableHandler.Context, locale: Locale = .current ) -> String { let matches = Self.extractVariables(from: string) var replacedString = string for variableMatch in matches.reversed() { - let replacementValue = provider.value(for: variableMatch.variable, locale: locale) + let replacementValue = provider.value(for: variableMatch.variable, + context: context, + locale: locale) replacedString = replacedString.replacingCharacters(in: variableMatch.range, with: replacementValue) } @@ -176,6 +210,7 @@ private enum VariableHandlerIOS15 { static func processVariables( in string: String, with provider: VariableDataProvider, + context: VariableHandler.Context, locale: Locale = .current ) -> String { var replacedString = string @@ -185,7 +220,9 @@ private enum VariableHandlerIOS15 { let variableNameRange = match.range(at: 1) if let variableNameRange = Range(variableNameRange, in: string) { let variableName = String(string[variableNameRange]) - let replacementValue = provider.value(for: variableName, locale: locale) + let replacementValue = provider.value(for: variableName, + context: context, + locale: locale) let adjustedRange = NSRange( location: variableNameRange.lowerBound.utf16Offset(in: string) - Self.pattern.count / 2, diff --git a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift index 991122ee00..2ca07bb37a 100644 --- a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift +++ b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift @@ -66,6 +66,12 @@ extension Package: VariableDataProvider { } } + func localizedRelativeDiscount(_ discount: Double?, _ locale: Locale) -> String? { + guard let discount else { return nil } + + return Localization.localized(discount: discount, locale: locale) + } + } // MARK: - Private diff --git a/RevenueCatUI/Resources/en.lproj/Localizable.strings b/RevenueCatUI/Resources/en.lproj/Localizable.strings index 88631ea105..079ac27f56 100644 --- a/RevenueCatUI/Resources/en.lproj/Localizable.strings +++ b/RevenueCatUI/Resources/en.lproj/Localizable.strings @@ -14,3 +14,4 @@ "Monthly" = "Monthly"; "Weekly" = "Weekly"; "Lifetime" = "Lifetime"; +"%d%% off" = "%d%% off"; diff --git a/RevenueCatUI/Views/PurchaseButton.swift b/RevenueCatUI/Views/PurchaseButton.swift index 8f390bc8da..6de771a9d3 100644 --- a/RevenueCatUI/Views/PurchaseButton.swift +++ b/RevenueCatUI/Views/PurchaseButton.swift @@ -165,8 +165,11 @@ struct PurchaseButton_Previews: PreviewProvider { private static let package: TemplateViewConfiguration.Package = .init( content: TestData.packageWithIntroOffer, - localization: TestData.localization1.processVariables(with: TestData.packageWithIntroOffer, - locale: .current), + localization: TestData.localization1.processVariables( + with: TestData.packageWithIntroOffer, + context: .init(discountRelativeToMostExpensivePerMonth: nil), + locale: .current + ), currentlySubscribed: Bool.random(), discountRelativeToMostExpensivePerMonth: nil ) diff --git a/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift b/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift index bcb8f84a86..7199c1bb4b 100644 --- a/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift +++ b/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift @@ -107,6 +107,16 @@ class PackageVariablesTests: TestCase { expect(TestData.lifetimePackage.localizedIntroductoryOfferPrice).to(beNil()) } + func testEnglishRelativeDiscount() { + expect(TestData.weeklyPackage.localizedRelativeDiscount(nil, Self.english)).to(beNil()) + expect(TestData.weeklyPackage.localizedRelativeDiscount(0.372, Self.english)) == "37% off" + } + + func testSpanishRelativeDiscount() { + expect(TestData.weeklyPackage.localizedRelativeDiscount(nil, Self.spanish)).to(beNil()) + expect(TestData.weeklyPackage.localizedRelativeDiscount(0.372, Self.english)) == "ahorra 37%" + } + } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/Tests/RevenueCatUITests/Data/VariablesTests.swift b/Tests/RevenueCatUITests/Data/VariablesTests.swift index 405efb60a6..9c2d79e64a 100644 --- a/Tests/RevenueCatUITests/Data/VariablesTests.swift +++ b/Tests/RevenueCatUITests/Data/VariablesTests.swift @@ -18,11 +18,13 @@ import XCTest class VariablesTests: TestCase { private var provider: MockVariableProvider! + private var context: VariableHandler.Context! override func setUp() { super.setUp() self.provider = .init() + self.context = .init(discountRelativeToMostExpensivePerMonth: nil) } func testEmptyString() { @@ -87,6 +89,11 @@ class VariablesTests: TestCase { expect(self.process("{{ sub_offer_price }}")) == self.provider.localizedIntroductoryOfferPrice } + func testRelativeDiscount() { + self.provider.relativeDiscount = "30% off" + expect(self.process("{{ sub_relative_discount }}")) == "30% off" + } + func testMultipleVariables() { self.provider.productName = "Pro" self.provider.localizedPricePerMonth = "$1.99" @@ -113,11 +120,14 @@ class VariablesTests: TestCase { content: "Trial lasts {{ sub_offer_duration }}", iconID: nil), .init(title: "Only {{ price }}", - content: "{{ sub_period }} subscription", + content: "{{ sub_period }} subscription, {{ sub_relative_discount }}", iconID: nil) ] ) - let processed = configuration.processVariables(with: TestData.packageWithIntroOffer) + let processed = configuration.processVariables( + with: TestData.packageWithIntroOffer, + context: .init(discountRelativeToMostExpensivePerMonth: 0.3) + ) expect(processed.title) == "Buy PRO monthly for xctest" expect(processed.subtitle) == "Price: $3.99" @@ -131,7 +141,7 @@ class VariablesTests: TestCase { content: "Trial lasts 1 week", iconID: nil), .init(title: "Only $3.99", - content: "Monthly subscription", + content: "Monthly subscription, 30% off", iconID: nil) ] } @@ -139,32 +149,32 @@ class VariablesTests: TestCase { // Note: this isn't perfect, but a warning is logged // and it's better than crashing. func testPricePerMonthForLifetimeProductsReturnsPrice() { - let result = VariableHandler.processVariables( - in: "{{ sub_price_per_month }}", + let result = self.process( + "{{ sub_price_per_month }}", with: TestData.lifetimePackage ) expect(result) == "$119.49" } func testTotalPriceAndPerMonthForLifetimeProductsReturnsPrice() { - let result = VariableHandler.processVariables( - in: "{{ total_price_and_per_month }}", + let result = self.process( + "{{ total_price_and_per_month }}", with: TestData.lifetimePackage ) expect(result) == "$119.49" } func testTotalPriceAndPerMonthForForMonthlyPackage() { - let result = VariableHandler.processVariables( - in: "{{ total_price_and_per_month }}", + let result = self.process( + "{{ total_price_and_per_month }}", with: TestData.monthlyPackage ) expect(result) == "$6.99/mo" } func testTotalPriceAndPerMonthForCustomMonthlyProductsReturnsPrice() { - let result = VariableHandler.processVariables( - in: "{{ total_price_and_per_month }}", + let result = self.process( + "{{ total_price_and_per_month }}", with: Package( identifier: "custom", packageType: .custom, @@ -176,8 +186,8 @@ class VariablesTests: TestCase { } func testTotalPriceAndPerMonthForCustomAnnualProductsReturnsPriceAndPerMonth() { - let result = VariableHandler.processVariables( - in: "{{ total_price_and_per_month }}", + let result = self.process( + "{{ total_price_and_per_month }}", with: Package( identifier: "custom", packageType: .custom, @@ -188,13 +198,27 @@ class VariablesTests: TestCase { expect(result) == "$53.99 ($4.49/mo)" } - // MARK: - validation + func testRelativeDiscountWithNoDiscount() { + self.context.discountRelativeToMostExpensivePerMonth = nil + let result = self.process("{{ sub_relative_discount }}", with: TestData.monthlyPackage) + + expect(result) == "" + } + + func testRelativeDiscountWithDiscount() { + self.context.discountRelativeToMostExpensivePerMonth = 0.3 + let result = self.process("{{ sub_relative_discount }}", with: TestData.monthlyPackage) + + expect(result) == "30% off" + } + + // MARK: - Validation func testNoUnrecognizedVariables() { let allVariables = "{{ app_name }} {{ price }} {{ price_per_period }} " + "{{ total_price_and_per_month }} {{ product_name }} {{ sub_period }} " + "{{ sub_price_per_month }} {{ sub_duration }} {{ sub_offer_duration }} " + - "{{ sub_offer_price }}" + "{{ sub_offer_price }} {{ sub_relative_discount }}" expect("".unrecognizedVariables()).to(beEmpty()) expect(allVariables.unrecognizedVariables()).to(beEmpty()) @@ -214,8 +238,17 @@ class VariablesTests: TestCase { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension VariablesTests { - func process(_ string: String, locale: Locale = .current) -> String { - return VariableHandler.processVariables(in: string, with: self.provider, locale: locale) + func process( + _ string: String, + with provider: VariableDataProvider? = nil, + locale: Locale = .current + ) -> String { + return VariableHandler.processVariables( + in: string, + with: provider ?? self.provider, + context: self.context, + locale: locale + ) } } @@ -232,6 +265,7 @@ private struct MockVariableProvider: VariableDataProvider { var subscriptionDuration: String? var introductoryOfferDuration: String? var introductoryOfferPrice: String = "" + var relativeDiscount: String? func periodName(_ locale: Locale) -> String { return self.periodName @@ -257,4 +291,8 @@ private struct MockVariableProvider: VariableDataProvider { return self.introductoryOfferPrice } + func localizedRelativeDiscount(_ discount: Double?, _ locale: Locale) -> String? { + return self.relativeDiscount + } + }