From 558656212dbfad6a522fbda304b1aaca45653b0b Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 2 Aug 2023 10:00:24 -0700 Subject: [PATCH] `Paywalls`: added ability to calculate and localize subscription discounts (#2943) ![Screenshot 2023-08-02 at 08 17 45](https://github.com/RevenueCat/purchases-ios/assets/685609/0c3ee7b8-1b37-4e6d-81d0-4b2d18086d82) Useful for template 4, but also for others. --- RevenueCatUI/Data/Localization.swift | 14 +++++ .../Data/TemplateViewConfiguration.swift | 44 +++++++++++++-- .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Data/TemplateViewConfigurationTests.swift | 4 ++ .../RevenueCatUITests/LocalizationTests.swift | 56 +++++++++++++++++++ 6 files changed, 116 insertions(+), 6 deletions(-) diff --git a/RevenueCatUI/Data/Localization.swift b/RevenueCatUI/Data/Localization.swift index 930dd3795a..bb9463e70f 100644 --- a/RevenueCatUI/Data/Localization.swift +++ b/RevenueCatUI/Data/Localization.swift @@ -89,6 +89,20 @@ enum Localization { return path.flatMap(Bundle.init(path:)) ?? containerBundle } + /// - Returns: localized string for a discount. Example: "37% off" + static func localized( + discount: Double, + locale: Locale + ) -> String { + assert(discount >= 0, "Invalid discount: \(discount)") + + let number = Int((discount * 100).rounded(.toNearestOrAwayFromZero)) + let format = self.localizedBundle(locale) + .localizedString(forKey: "%d%% off", value: nil, table: nil) + + return String(format: format, number) + } + } // MARK: - Private diff --git a/RevenueCatUI/Data/TemplateViewConfiguration.swift b/RevenueCatUI/Data/TemplateViewConfiguration.swift index a050209e8a..eb718e9087 100644 --- a/RevenueCatUI/Data/TemplateViewConfiguration.swift +++ b/RevenueCatUI/Data/TemplateViewConfiguration.swift @@ -30,6 +30,7 @@ extension TemplateViewConfiguration { let content: RevenueCat.Package let localization: ProcessedLocalizedConfiguration + let discountRelativeToMostExpensivePerMonth: Double? } @@ -107,16 +108,22 @@ extension TemplateViewConfiguration.PackageConfiguration { guard !packages.isEmpty else { throw TemplateError.noPackages } guard !filter.isEmpty else { throw TemplateError.emptyPackageList } - let filtered = TemplateViewConfiguration - .filter(packages: packages, with: filter) + let filtered = TemplateViewConfiguration.filter(packages: packages, with: filter) + let mostExpensivePricePerMonth = Self.mostExpensivePricePerMonth(in: filtered) + + let filteredPackages = filtered .map { package in TemplateViewConfiguration.Package( content: package, - localization: localization.processVariables(with: package, locale: locale) + localization: localization.processVariables(with: package, locale: locale), + discountRelativeToMostExpensivePerMonth: Self.discount( + from: package.storeProduct.pricePerMonth?.doubleValue, + relativeTo: mostExpensivePricePerMonth + ) ) } - guard let firstPackage = filtered.first else { + guard let firstPackage = filteredPackages.first else { throw TemplateError.couldNotFindAnyPackages(expectedTypes: filter) } @@ -124,16 +131,41 @@ extension TemplateViewConfiguration.PackageConfiguration { case .single: return .single(firstPackage) case .multiple: - let defaultPackage = filtered + let defaultPackage = filteredPackages .first { $0.content.packageType == `default` } ?? firstPackage return .multiple(first: firstPackage, default: defaultPackage, - all: filtered) + all: filteredPackages) } } + private static func mostExpensivePricePerMonth(in packages: [Package]) -> Double? { + return packages + .lazy + .map(\.storeProduct) + .compactMap { product in + product.pricePerMonth.map { + return ( + product: product, + pricePerMonth: $0 + ) + } + } + .max { productA, productB in + return productA.pricePerMonth.doubleValue < productB.pricePerMonth.doubleValue + } + .map(\.pricePerMonth.doubleValue) + } + + private static func discount(from pricePerMonth: Double?, relativeTo mostExpensive: Double?) -> Double? { + guard let pricePerMonth, let mostExpensive else { return nil } + guard pricePerMonth < mostExpensive else { return nil } + + return (mostExpensive - pricePerMonth) / mostExpensive + } + } @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) diff --git a/RevenueCatUI/Resources/en.lproj/Localizable.strings b/RevenueCatUI/Resources/en.lproj/Localizable.strings index 2d2bed9aef..6b2e53694d 100644 --- a/RevenueCatUI/Resources/en.lproj/Localizable.strings +++ b/RevenueCatUI/Resources/en.lproj/Localizable.strings @@ -13,3 +13,5 @@ "PackageType.twoMonth" = "2 month"; "PackageType.monthly" = "Monthly"; "PackageType.weekly" = "Weekly"; + +"%d%% off" = "%d%% off"; diff --git a/RevenueCatUI/Resources/es.lproj/Localizable.strings b/RevenueCatUI/Resources/es.lproj/Localizable.strings index 11ba6ed3a4..1bafeb9eab 100644 --- a/RevenueCatUI/Resources/es.lproj/Localizable.strings +++ b/RevenueCatUI/Resources/es.lproj/Localizable.strings @@ -13,3 +13,5 @@ "PackageType.twoMonth" = "2 meses"; "PackageType.monthly" = "Mensual"; "PackageType.weekly" = "Semanal"; + +"%d%% off" = "Ahorra %d%%"; diff --git a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift index 8fba72df81..41bb3267e8 100644 --- a/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift +++ b/Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift @@ -54,6 +54,7 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests switch result { case let .single(package): expect(package.content) === TestData.monthlyPackage + expect(package.discountRelativeToMostExpensivePerMonth).to(beNil()) Self.verifyLocalizationWasProcessed(package.localization, for: TestData.monthlyPackage) case .multiple: fail("Invalid result: \(result)") @@ -80,10 +81,13 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests let annual = packages[0] expect(annual.content) === TestData.annualPackage + expect(annual.discountRelativeToMostExpensivePerMonth) + .to(beCloseTo(0.55, within: 0.01)) Self.verifyLocalizationWasProcessed(annual.localization, for: TestData.annualPackage) let monthly = packages[1] expect(monthly.content) === TestData.monthlyPackage + expect(monthly.discountRelativeToMostExpensivePerMonth).to(beNil()) Self.verifyLocalizationWasProcessed(monthly.localization, for: TestData.monthlyPackage) } } diff --git a/Tests/RevenueCatUITests/LocalizationTests.swift b/Tests/RevenueCatUITests/LocalizationTests.swift index 1a9b24b76d..55541ad664 100644 --- a/Tests/RevenueCatUITests/LocalizationTests.swift +++ b/Tests/RevenueCatUITests/LocalizationTests.swift @@ -180,6 +180,51 @@ class PackageTypeOtherLanguageLocalizationTests: BaseLocalizationTests { } } +// MARK: - Discount + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +class DiscountEnglishLocalizationTests: BaseLocalizationTests { + + override var locale: Locale { return .init(identifier: "en_US") } + + func testLocalization() { + verify(0, "0% off") + verify(0.1, "10% off") + verify(0.331, "33% off") + verify(0.5, "50% off") + verify(1, "100% off") + verify(1.1, "110% off") + } + +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +class DiscountSpanishLocalizationTests: BaseLocalizationTests { + + override var locale: Locale { return .init(identifier: "es_ES") } + + func testLocalization() { + verify(0, "Ahorra 0%") + verify(0.1, "Ahorra 10%") + verify(0.331, "Ahorra 33%") + verify(0.5, "Ahorra 50%") + verify(1, "Ahorra 100%") + verify(1.1, "Ahorra 110%") + } + +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, *) +class DiscountOtherLanguageLocalizationTests: BaseLocalizationTests { + + override var locale: Locale { return .init(identifier: "fr") } + + func testLocalizationDefaultsToEnglish() { + verify(1, "100% off") + } + +} + // MARK: - Private @available(iOS 16.0, macOS 13.0, tvOS 16.0, *) @@ -219,4 +264,15 @@ private extension BaseLocalizationTests { expect(file: file, line: line, result) == expected } + func verify( + _ discount: Double, + _ expected: String, + file: StaticString = #file, + line: UInt = #line + ) { + let result = Localization.localized(discount: discount, + locale: self.locale) + expect(file: file, line: line, result) == expected + } + }