Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AdaptyPrice localizedString removes 2nd decimal if it is 0 in .getPaywallProducts #123

Open
pkiler opened this issue Jun 26, 2024 · 19 comments
Assignees
Labels
bug Something isn't working

Comments

@pkiler
Copy link

pkiler commented Jun 26, 2024

Description

Currently, requesting paywall products that have prices with their 2nd decimal as 0 seems to truncate this decimal.

Example response from fetch/get-paywall-products (calling Adapty.getPaywallProducts):

{
    "data": [
        {
            [...],
            "price": {
                "currency_code": "PEN",
                "amount": 19.9,
                "localized_string": "S/ 19.9",
                "currency_symbol": "S/"
            },
            "region_code": "PE",
        },
        {
            [...],
            "price": {
                "currency_symbol": "S/",
                "currency_code": "PEN",
                "amount": 39.9,
                "localized_string": "S/ 39.9"
            },
            "region_code": "PE"
        }
    ],
    "type": "Array<AdaptyPaywallProduct>"
}

But calling Adapty.makePurchase opens the native purchase window, with the price displayed with its 2 decimals as expected:

image

Currency in the screenshot is PEN, but might be a general issue with other currencies.

Version

2.10.1

What platforms are you seeing the problem on?

iOS

System info

System:
  OS: macOS 14.5
  CPU: (8) arm64 Apple M3
  Memory: 153.58 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 18.18.2
    path: ~/.nvm/versions/node/v18.18.2/bin/node
  Yarn:
    version: 1.22.21
    path: ~/.nvm/versions/node/v18.18.2/bin/yarn
  npm:
    version: 9.8.1
    path: ~/.nvm/versions/node/v18.18.2/bin/npm
  Watchman:
    version: 2024.06.10.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.15.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 23.5
      - iOS 17.5
      - macOS 14.5
      - tvOS 17.5
      - visionOS 1.2
      - watchOS 10.5
  Android SDK: Not Found
IDEs:
  Android Studio: 2024.1 AI-241.15989.150.2411.11948838
  Xcode:
    version: 15.4/15F31d
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.11
    path: /usr/bin/javac
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 18.2.0
    wanted: 18.2.0
  react-native:
    installed: 0.73.6
    wanted: 0.73.6
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: false
iOS:
  hermesEnabled: true
  newArchEnabled: false
@pkiler pkiler added the bug Something isn't working label Jun 26, 2024
@pkiler pkiler changed the title AdaptyPrice localizedString removes 2nd decimal if it is 0 AdaptyPrice localizedString removes 2nd decimal if it is 0 in .getPaywallProducts Jun 26, 2024
@efstathiosntonas
Copy link

efstathiosntonas commented Jul 13, 2024

I've chatted with support about this, they had no clue what is going on.

I used Intl and react-native-localize to solve this:

import * as RNLocalize from 'react-native-localize';

export function normalizePrice(amount: number, currencyCode: string) {
  const locale = RNLocalize.getLocales()[0].languageTag;
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: currencyCode,
    minimumFractionDigits: 2
  }).format(amount);
}

usage:

 normalizePrice(
     product?.price?.amount as number,
     product?.price?.currencyCode as string
   )

@efstathiosntonas
Copy link

efstathiosntonas commented Jul 19, 2024

Is anyone home? It's been 3 weeks since the issue was raised and the Intl method is prone to issues. You know we're paying (tons of) money for your product right? We expect support to be optimal at least.

I've opened a ticket through Adapty's dashboard Help but the operators had no clue about this and they didn't even tried to forward this to your technical team. 👎

@efstathiosntonas
Copy link

Day 35 and noone from adapty team has replied. Terrible support.

@pkiler
Copy link
Author

pkiler commented Aug 1, 2024

Yes just a timeline or at least a response on this could be great 😕

@vladd-g
Copy link
Contributor

vladd-g commented Aug 6, 2024

@pkiler @efstathiosntonas hey, we sincerely apologize for the inconvenience

we're investigating the issue now and will update you as soon as we have more information

@efstathiosntonas
Copy link

@vladd-g any update on this, it's been 1.5 months.....

@vladd-g
Copy link
Contributor

vladd-g commented Aug 13, 2024

@efstathiosntonas, sorry for the wait. We'll let you know as soon as it's resolved

@efstathiosntonas
Copy link

@vladd-g any updates on this one? I hope you guys realize how IMPORTANT price formatting is for the buyers, right?

@vladd-g
Copy link
Contributor

vladd-g commented Aug 26, 2024

@efstathiosntonas expecting to release it within 2 weeks

@vladd-g
Copy link
Contributor

vladd-g commented Sep 13, 2024

@efstathiosntonas @pkiler could you please update to v2.11.3? and to v2.11.1 of AdaptyUI if you use it

@efstathiosntonas
Copy link

@vladd-g same thing, price should be $44.00. I've unistalled the app just in case something was cached prior testing.

{
  "vendorProductId": "xxxxx",
  "adaptyId": "xxxxx",
  "localizedDescription": "xxxxxx,
  "localizedTitle": "xxxxx",
  "regionCode": "US",
  "variationId": "xxxx",
  "paywallABTestName": "Subscriptions",
  "paywallName": "Subscriptions",
  "price": {
    "amount": 44,
    "currencyCode": "USD",
    "currencySymbol": "$",
    "localizedString": "$44"
  },
  "subscriptionDetails": {
    "subscriptionPeriod": {
      "unit": "month",
      "numberOfUnits": 1
    },
    "localizedSubscriptionPeriod": "1 month",
    "ios": {
      "subscriptionGroupIdentifier": "xxxx"
    },
    "android": {
      "renewalType": "autorenewable"
    }
  },
  "ios": {
    "isFamilyShareable": false
  }
}

@vladd-g
Copy link
Contributor

vladd-g commented Sep 13, 2024

@efstathiosntonas We've studied how people and e-commerce in general display prices and found out that having decimal zeroes for whole prices isn't exactly a universal practice. Some services do display it, some don't. In our library we opted for a cleaner look for all the platforms.

Also, when considering prices in App Store/Play Store in US/Europe — those typically have decimals of ".99" anyway, so this is rarely an issue. But there are countries with whole prices of in-app purchases in the range of hundreds, where having trailing ".00" takes up too much space.

In summary: we opted for having 2 decimals for fractional prices for most cases and displaying whole numbers without the zero decimal part. However, you can alter the price rendering logic for your app should you decide to do so.

@efstathiosntonas
Copy link

efstathiosntonas commented Sep 13, 2024

@vladd-g What you studied versus the monetization standards for years is a totally different thing.

This:

where having trailing ".00" takes up too much space.

can be easily solved with an one line to strip out trailing fractions while the other way around is way harder and might lead to inaccurate results.

A simple question to ChatGPT throws a bunch of reasons why prices should include decimals, even .00 ones.

At least give us a config option to always display .00 decimals or not.

Screenshot 2024-09-13 at 19 29 27

@efstathiosntonas
Copy link

just taken this from amazon moments ago, as you can see, your study is totally wrong.

Screenshot 2024-09-13 at 19 36 27

@vladd-g
Copy link
Contributor

vladd-g commented Sep 13, 2024

  • ChatGPT's arguments have merit and are valid, but in the context of compact mobile layout and diversity of locales (including those with large prices), we believe that our approach also has the right to exist
  • We also studied Amazon, but it's not entirely relevant because they have price comparisons throughout their product (that strengthens ChatGPT's arguments), plus a desktop interface, plus differently laid out prices (cents are not placed on the same line as the price)
  • Speaking specifically about $44.00, we'd like to ask — have you considered making the price $43.99, as in this case it's a best practice and such prices convert better?
  • Maybe I'm missing something, but don't you already use a workaround like the one you've provided above in the comments?

@efstathiosntonas
Copy link

efstathiosntonas commented Sep 14, 2024

@pkiler since they don't seem to understand the importance of this in favor of UI issues with long prices (lol) and my request to add a config value about it fell into void, use patch-package and cocoapods-patch:

react-native-adapty+2.11.3.patch:

diff --git a/node_modules/react-native-adapty/dist/adapty-handler.js b/node_modules/react-native-adapty/dist/adapty-handler.js
index c478d1e..f0087a5 100644
--- a/node_modules/react-native-adapty/dist/adapty-handler.js
+++ b/node_modules/react-native-adapty/dist/adapty-handler.js
@@ -110,6 +110,7 @@ class Adapty {
         return tslib_1.__awaiter(this, void 0, void 0, function* () {
             // call before log ctx calls, so no logs are lost
             const logLevel = params.logLevel;
+            const integers = params.integers;
             logger_1.Log.logLevel = logLevel || null;
             const ctx = new logger_1.LogContext();
             const log = ctx.call({ methodName: 'activate' });
@@ -128,6 +129,9 @@ class Adapty {
             if (logLevel) {
                 body.set('log_level', logLevel);
             }
+            if (react_native_1.Platform.OS === 'ios') {
+                body.set('integers', integers);
+            }
             if (react_native_1.Platform.OS === 'ios') {
                 if ((_a = params.ios) === null || _a === void 0 ? void 0 : _a.idfaCollectionDisabled) {
                     body.set('idfa_collection_disabled', params.ios.idfaCollectionDisabled);
diff --git a/node_modules/react-native-adapty/dist/bridge.d.ts b/node_modules/react-native-adapty/dist/bridge.d.ts
index aed9fbe..fdb5731 100644
--- a/node_modules/react-native-adapty/dist/bridge.d.ts
+++ b/node_modules/react-native-adapty/dist/bridge.d.ts
@@ -13,5 +13,5 @@ export declare const MODULE_NAME = "RNAdapty";
 export declare class ParamMap extends GenericParamMap<ParamKey> {
     constructor();
 }
-export declare const $bridge: NativeRequestHandler<"activate" | "get_paywall" | "get_paywall_for_default_audience" | "get_paywall_products" | "get_products_introductory_offer_eligibility" | "get_profile" | "identify" | "log_show_onboarding" | "log_show_paywall" | "logout" | "make_purchase" | "not_implemented" | "present_code_redemption_sheet" | "restore_purchases" | "set_fallback_paywalls" | "set_log_level" | "set_variation_id" | "update_attribution" | "update_profile" | "__test__", ParamMap>;
+export declare const $bridge: NativeRequestHandler<"activate" | "get_paywall" | "get_paywall_for_default_audience" | "get_paywall_products" | "get_products_introductory_offer_eligibility" | "get_profile" | "identify" | "log_show_onboarding" | "log_show_paywall" | "logout" | "make_purchase" | "not_implemented" | "present_code_redemption_sheet" | "restore_purchases" | "set_fallback_paywalls" | "set_log_level" | "set_variation_id" | "update_attribution" | "update_profile" | "__test__", "integers", ParamMap>;
 //# sourceMappingURL=bridge.d.ts.map
diff --git a/node_modules/react-native-adapty/dist/types/inputs.d.ts b/node_modules/react-native-adapty/dist/types/inputs.d.ts
index a1dda54..dc9b7ec 100644
--- a/node_modules/react-native-adapty/dist/types/inputs.d.ts
+++ b/node_modules/react-native-adapty/dist/types/inputs.d.ts
@@ -133,6 +133,11 @@ export interface ActivateParamsInput {
          */
         idfaCollectionDisabled?: boolean;
     };
+    /**
+     * if set to false then prices that have .00 will be displayed
+     * $1.00 instead of $1, defaults to true
+     */
+    integers?: boolean;
 }
 export interface GetPaywallProductsParamsInput {
 }
diff --git a/node_modules/react-native-adapty/lib/ios/RNAConstants.swift b/node_modules/react-native-adapty/lib/ios/RNAConstants.swift
index 3efe59f..824c58c 100644
--- a/node_modules/react-native-adapty/lib/ios/RNAConstants.swift
+++ b/node_modules/react-native-adapty/lib/ios/RNAConstants.swift
@@ -28,6 +28,7 @@ public enum ParamKey: String {
     case prefetch_products = "prefetch_products"
     case custom_tags = "custom_tags"
     case view_id = "view_id"
+    case integers = "integers"
 }

 public enum MethodName: String {
diff --git a/node_modules/react-native-adapty/lib/ios/RNAdapty.swift b/node_modules/react-native-adapty/lib/ios/RNAdapty.swift
index e546b50..165c465 100644
--- a/node_modules/react-native-adapty/lib/ios/RNAdapty.swift
+++ b/node_modules/react-native-adapty/lib/ios/RNAdapty.swift
@@ -217,6 +217,7 @@ class RNAdapty: RCTEventEmitter, AdaptyDelegate {
         let observerMode: Bool? = ctx.params.getOptionalValue(for: .observerMode)
         let idfaCollectionDisabled: Bool? = ctx.params.getOptionalValue(for: .idfaDisabled)
         let ipAddressCollectionDisabled: Bool? = ctx.params.getOptionalValue(for: .ipAddressCollectionDisabled)
+        let integers: Bool? = ctx.params.getOptionalValue(for: .integers)

         // Memoize activation args
         MEMO_ACTIVATION_ARGS[ParamKey.sdkKey.rawValue] = apiKey
@@ -236,6 +237,7 @@ class RNAdapty: RCTEventEmitter, AdaptyDelegate {
             .with(customerUserId: customerUserId)
             .with(ipAddressCollectionDisabled: ipAddressCollectionDisabled ?? false)
             .with(idfaCollectionDisabled: idfaCollectionDisabled ?? false)
+            .with(integers: integers ?? true)
             .build()

         Adapty.activate(with: configuration) { maybeErr in ctx.okOrForwardError(maybeErr) }

place this under ios/patches/Adapty+2.11.3.diff:

diff --git a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Adapty+ChangeState.swift b/Pods/Adapty/Sources/Adapty+ChangeState.swift
index 791ae422d..93fe01185 100644
--- a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Adapty+ChangeState.swift
+++ b/Pods/Adapty/Sources/Adapty+ChangeState.swift
@@ -12,6 +12,7 @@ public final class Adapty {
     let profileStorage: ProfileStorage
     let apiKeyPrefix: String
     let backend: Backend
+    let integers: Bool
 
     let httpSession: HTTPSession
     lazy var httpFallbackSession: HTTPSession = {
@@ -34,10 +35,12 @@ public final class Adapty {
         profileStorage: ProfileStorage,
         vendorIdsStorage: ProductVendorIdsStorage,
         backend: Backend,
-        customerUserId: String?
+        customerUserId: String?,
+        integers: Bool
     ) {
         self.apiKeyPrefix = apiKeyPrefix
         self.backend = backend
+        self.integers = integers
         
         self.profileStorage = profileStorage
         vendorIdsCache = ProductVendorIdsCache(storage: vendorIdsStorage)
diff --git a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Adapty.swift b/Pods/Adapty/Sources/Adapty.swift
index d36426398..245900e91 100644
--- a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Adapty.swift
+++ b/Pods/Adapty/Sources/Adapty.swift
@@ -71,7 +71,7 @@ extension Adapty {
             "observer_mode": .value(configuration.observerMode),
             "has_customer_user_id": .value(configuration.customerUserId != nil),
             "idfa_collection_disabled": .value(configuration.idfaCollectionDisabled),
-            "ip_address_collection_disabled": .value(configuration.ipAddressCollectionDisabled),
+            "ip_address_collection_disabled": .value(configuration.ipAddressCollectionDisabled)
         ]
 
         async(completion, logName: logName, logParams: logParams) { completion in
@@ -88,6 +88,7 @@ extension Adapty {
             Configuration.idfaCollectionDisabled = configuration.idfaCollectionDisabled
             Configuration.ipAddressCollectionDisabled = configuration.ipAddressCollectionDisabled
             Configuration.observerMode = configuration.observerMode
+            Configuration.integers = configuration.integers
 
             let backend = Backend(with: configuration)
 
@@ -98,12 +99,13 @@ extension Adapty {
                 profileStorage: UserDefaults.standard,
                 vendorIdsStorage: UserDefaults.standard,
                 backend: backend,
-                customerUserId: configuration.customerUserId
+                customerUserId: configuration.customerUserId,
+                integers: configuration.integers
             )
 
             LifecycleManager.shared.initialize()
 
-            Log.info("Adapty activated withObserverMode:\(configuration.observerMode), withCustomerUserId: \(configuration.customerUserId != nil)")
+            Log.info("Adapty activated withObserverMode:\(configuration.observerMode), withCustomerUserId: \(configuration.customerUserId != nil), integers: \(configuration.integers)")
             completion(nil)
         }
     }
diff --git a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Configuration.Builder.swift b/Pods/Adapty/Sources/Configuration.Builder.swift
index c8699b067..1ec16a9ad 100644
--- a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Configuration.Builder.swift
+++ b/Pods/Adapty/Sources/Configuration.Builder.swift
@@ -19,7 +19,8 @@ extension Adapty.Configuration {
             backendBaseUrl: builder.backendBaseUrl,
             backendFallbackBaseUrl: builder.backendFallbackBaseUrl,
             backendConfigsBaseUrl: builder.backendConfigsBaseUrl,
-            backendProxy: builder.backendProxy
+            backendProxy: builder.backendProxy,
+            integers: builder.integers
         )
     }
 
@@ -38,6 +39,7 @@ extension Adapty.Configuration {
         public private(set) var backendFallbackBaseUrl: URL
         public private(set) var backendConfigsBaseUrl: URL
         public private(set) var backendProxy: (host: String, port: Int)?
+        public private(set) var integers: Bool
 
         public convenience init(withAPIKey key: String) {
             assert(key.count >= 41 && key.starts(with: "public_live"), "It looks like you have passed the wrong apiKey value to the Adapty SDK.")
@@ -56,6 +58,7 @@ extension Adapty.Configuration {
             self.backendFallbackBaseUrl = configuration.backendFallbackBaseUrl
             self.backendConfigsBaseUrl = configuration.backendConfigsBaseUrl
             self.backendProxy = configuration.backendProxy
+            self.integers = configuration.integers
         }
 
         /// Call this method to get the ``Adapty.Configuration`` object.
@@ -117,5 +120,11 @@ extension Adapty.Configuration {
             backendProxy = (host: host, port: port)
             return self
         }
+        
+        /// - Parameter integers: A boolean value controlling prices format eg. $1.00 vs $1
+        public func with(integers value: Bool) -> Builder {
+            integers = value
+            return self
+        }
     }
 }
diff --git a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Configuration.swift b/Pods/Adapty/Sources/Configuration.swift
index 732a87542..ec7451631 100644
--- a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Configuration.swift
+++ b/Pods/Adapty/Sources/Configuration.swift
@@ -19,7 +19,8 @@ extension Adapty {
             backendBaseUrl: Backend.publicEnvironmentBaseUrl,
             backendFallbackBaseUrl: Backend.publicEnvironmentFallbackBaseUrl,
             backendConfigsBaseUrl: Backend.publicEnvironmentConfigsBaseUrl,
-            backendProxy: nil
+            backendProxy: nil,
+            integers: true
         )
 
         let apiKey: String
@@ -32,6 +33,7 @@ extension Adapty {
         let backendFallbackBaseUrl: URL
         let backendConfigsBaseUrl: URL
         let backendProxy: (host: String, port: Int)?
+        let integers: Bool
     }
 }
 
@@ -39,6 +41,7 @@ extension Adapty.Configuration {
     static var idfaCollectionDisabled: Bool = `default`.idfaCollectionDisabled
     static var ipAddressCollectionDisabled: Bool = `default`.ipAddressCollectionDisabled
     static var observerMode: Bool = `default`.observerMode
+    static var integers: Bool = `default`.integers
 }
 
 extension Adapty {
@@ -71,6 +74,8 @@ extension Adapty.Configuration: Decodable {
 
         case backendProxyHost = "backend_proxy_host"
         case backendProxyPort = "backend_proxy_port"
+        
+        case integers = "integers"
     }
 
     public init(from decoder: any Decoder) throws {
@@ -97,5 +102,7 @@ extension Adapty.Configuration: Decodable {
         } else {
             backendProxy = Self.default.backendProxy
         }
+        integers = try container.decodeIfPresent(Bool.self, forKey: .integers)
+            ?? Self.default.integers
     }
 }
diff --git a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Extensions/Locale+Extensions.swift b/Pods/Adapty/Sources/Extensions/Locale+Extensions.swift
index 7e6a5ed56..7ad3209a7 100644
--- a/cocoapods-patch-20240914-20930-9fo5ec/Adapty/Sources/Extensions/Locale+Extensions.swift
+++ b/Pods/Adapty/Sources/Extensions/Locale+Extensions.swift
@@ -22,6 +22,7 @@ public extension Locale {
     }
 }
 
+
 extension AdaptyExtension where Extended == Locale {
     var currencyCode: String? {
         guard #available(macOS 13, iOS 16, tvOS 16, watchOS 9, visionOS 1.0, *) else {
@@ -41,8 +42,9 @@ extension AdaptyExtension where Extended == Locale {
         let formatter = NumberFormatter()
         formatter.numberStyle = .currency
         formatter.locale = this
-
-        if price.isInteger {
+        let integers = Adapty.shared?.integers ?? true
+        
+        if integers && price.isInteger {
             formatter.minimumFractionDigits = 0
             formatter.maximumFractionDigits = 0
         }

On Gemfile add this and then run bundle install from the root of your project:
gem 'cocoapods-patch'

On ios/Podfile add this:
plugin 'cocoapods-patch'

then enter ios folder and pod install

usage:

await adapty.activate(<apiKey>, {
      __debugDeferActivation: __DEV__,
      integers: false <--- add this, setting it to true will show $1 as $1.00
    });

Note that if you change the integers on the fly, fast refresh won't pick it up, kill the app and reopen. Let me know if you faced any issues.

@efstathiosntonas
Copy link

efstathiosntonas commented Sep 14, 2024

Maybe I'm missing something, but don't you already use a workaround like the one you've provided above in the comments?

@vladd-g this is not stable because the device locale could be different from the Apple account region causing big issues with our customers seing prices in $ instead of € or vice versa or every other possible currency mismatch.

example: I live in Europe but my Apple account is US based

@pkiler
Copy link
Author

pkiler commented Sep 16, 2024

@vladd-g thank you for the update, 2.11.3 fixed the issue for us, our .9 are properly formatted into .90.

@pkiler
Copy link
Author

pkiler commented Sep 16, 2024

@efstathiosntonas thank you for sharing your detailed work around, in my case no prices are only whole numbers, so it was really only the second decimal that was missing from the localizedString. That said I would agree having the option to choose between displaying the .00 or not would be great, unsure if it would be related to my initial issue though.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

4 participants