diff --git a/.changeset/runtime-locale-identifier.md b/.changeset/runtime-locale-identifier.md new file mode 100644 index 00000000..e3dcbaf9 --- /dev/null +++ b/.changeset/runtime-locale-identifier.md @@ -0,0 +1,5 @@ +--- +"expo-superwall": minor +--- + +Expose `setLocaleIdentifier(string | null)` in the hooks and compat APIs to change the native locale override after configuration, or restore the device locale with `null`. diff --git a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt index 42a9d880..e7780a20 100644 --- a/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt +++ b/android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt @@ -572,6 +572,10 @@ class SuperwallExpoModule : Module() { } } + Function("setLocaleIdentifier") { localeIdentifier: String? -> + Superwall.instance.localeIdentifier = localeIdentifier + } + AsyncFunction("setIntegrationAttributes") { attributes: Map, promise: Promise -> scope.launch { try { diff --git a/ios/SuperwallExpoModule.swift b/ios/SuperwallExpoModule.swift index 76b25f81..650117de 100644 --- a/ios/SuperwallExpoModule.swift +++ b/ios/SuperwallExpoModule.swift @@ -448,6 +448,10 @@ public class SuperwallExpoModule: Module { } } + Function("setLocaleIdentifier") { (localeIdentifier: String?) in + Superwall.shared.localeIdentifier = localeIdentifier + } + AsyncFunction("setIntegrationAttributes") { (attributes: [String: String], promise: Promise) in var converted: [IntegrationAttribute: String] = [:] diff --git a/src/SuperwallExpoModule.ts b/src/SuperwallExpoModule.ts index f7edb0fc..a91f40a2 100644 --- a/src/SuperwallExpoModule.ts +++ b/src/SuperwallExpoModule.ts @@ -78,6 +78,7 @@ declare class SuperwallExpoModule extends NativeModule diff --git a/src/__tests__/compat.locale.test.ts b/src/__tests__/compat.locale.test.ts new file mode 100644 index 00000000..bcfba4af --- /dev/null +++ b/src/__tests__/compat.locale.test.ts @@ -0,0 +1,41 @@ +const mockConfigure = jest.fn().mockResolvedValue(undefined) +const mockSetLocaleIdentifier = jest.fn() + +// The compat configuration gate only needs an in-process event emitter. +jest.mock("expo", () => ({ EventEmitter: require("node:events").EventEmitter })) + +jest.mock("../SuperwallExpoModule", () => ({ + __esModule: true, + default: { + addListener: jest.fn(() => ({ remove: jest.fn() })), + configure: mockConfigure, + setLocaleIdentifier: mockSetLocaleIdentifier, + }, +})) + +const { default: Superwall }: typeof import("../compat") = require("../compat") + +describe("compat runtime locale", () => { + it("waits for configure, changes the locale, and resets without reconfiguring", async () => { + let resolveConfigure: (() => void) | undefined + mockConfigure.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfigure = resolve + }), + ) + + const pendingConfigure = Superwall.configure({ apiKey: "api-key" }) + const pendingLocale = Superwall.shared.setLocaleIdentifier("es_ES") + await Promise.resolve() + expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() + + resolveConfigure?.() + await pendingConfigure + await pendingLocale + expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES") + + await Superwall.shared.setLocaleIdentifier(null) + expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]]) + expect(mockConfigure).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/__tests__/sdk.behavior.test.tsx b/src/__tests__/sdk.behavior.test.tsx index e36b799b..f18f908f 100644 --- a/src/__tests__/sdk.behavior.test.tsx +++ b/src/__tests__/sdk.behavior.test.tsx @@ -17,6 +17,7 @@ const mockGetCustomerInfo = jest.fn().mockResolvedValue({ const mockSetSubscriptionStatus = jest.fn().mockResolvedValue(undefined) const mockSetIntegrationAttributes = jest.fn().mockResolvedValue(undefined) const mockTogglePaywallSpinner = jest.fn() +const mockSetLocaleIdentifier = jest.fn() const mockAddListener = jest.fn( (eventName: string, listener: (payload: any) => void): { remove: () => void } => { const listeners = mockListeners.get(eventName) ?? new Set() @@ -65,6 +66,7 @@ jest.mock("../SuperwallExpoModule", () => ({ didHandleBackPressed: mockDidHandleBackPressed, didHandleCustomCallback: mockDidHandleCustomCallback, togglePaywallSpinner: mockTogglePaywallSpinner, + setLocaleIdentifier: mockSetLocaleIdentifier, }, })) @@ -321,6 +323,30 @@ describe("SDK behavior regressions", () => { expect(mockSetSubscriptionStatus).toHaveBeenCalledWith({ status: "INACTIVE" }) }) + it("waits for configure before changing the locale and can reset to the device locale", async () => { + let resolveConfigure: ((value: boolean) => void) | undefined + mockConfigure.mockReturnValueOnce( + new Promise((resolve) => { + resolveConfigure = resolve + }), + ) + + const pendingLocale = useSuperwallStore.getState().setLocaleIdentifier("es_ES") + const pendingConfigure = useSuperwallStore.getState().configure("api-key") + await Promise.resolve() + + expect(mockSetLocaleIdentifier).not.toHaveBeenCalled() + + resolveConfigure?.(true) + await pendingConfigure + await pendingLocale + expect(mockSetLocaleIdentifier).toHaveBeenCalledWith("es_ES") + + await useSuperwallStore.getState().setLocaleIdentifier(null) + expect(mockSetLocaleIdentifier.mock.calls).toEqual([["es_ES"], [null]]) + expect(mockConfigure).toHaveBeenCalledTimes(1) + }) + it("waits for configure before setting integration attributes", async () => { let resolveConfigure: ((value: boolean) => void) | undefined mockConfigure.mockReturnValueOnce( diff --git a/src/compat/index.ts b/src/compat/index.ts index 2bf5cf56..495c9436 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -846,6 +846,18 @@ export default class Superwall { await SuperwallExpoModule.setEventTrackingBehavior(behavior.toString()) } + /** + * Sets the locale identifier for the Superwall SDK. + * This determines the language used when presenting paywalls. + * Can be changed at runtime without needing to reconfigure. + * + * @param localeIdentifier - The locale identifier (e.g., "en_US", "es_ES"), or `null` to reset to the device locale. + */ + async setLocaleIdentifier(localeIdentifier: string | null): Promise { + await this.awaitConfig() + await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + } + /** * Sets attributes for third-party integrations. * @param attributes - Object mapping IntegrationAttribute string values to their IDs diff --git a/src/useSuperwall.ts b/src/useSuperwall.ts index 1420b236..e28301d6 100644 --- a/src/useSuperwall.ts +++ b/src/useSuperwall.ts @@ -297,6 +297,15 @@ export interface SuperwallStore { */ setLogLevel: (level: string) => Promise + /** + * Sets the locale identifier for the Superwall SDK. + * This determines the language used when presenting paywalls. + * Can be changed at runtime without needing to reconfigure. + * @param localeIdentifier - The locale identifier (e.g., "en", "es", "fr"), or `null` to reset to the device locale. + * @returns A promise that resolves when the locale identifier is set. + */ + setLocaleIdentifier: (localeIdentifier: string | null) => Promise + /** * Sets which events Superwall tracks at runtime, for GDPR/data-collection control. * @param behavior - The desired event tracking behavior ("all", "superwallOnly", or "none"). @@ -438,7 +447,7 @@ export const useSuperwallStore = create((set, get) => ({ identify: async (userId, options) => { await awaitConfigured() - // The previous identity's purchases must not leak into the new one. + // The previous identity's purchases must not leak into the new one. set({ customerInfo: null }) await SuperwallExpoModule.identify(userId, options) @@ -526,6 +535,11 @@ export const useSuperwallStore = create((set, get) => ({ await SuperwallExpoModule.setEventTrackingBehavior(behavior) }, + setLocaleIdentifier: async (localeIdentifier) => { + await awaitConfigured() + await SuperwallExpoModule.setLocaleIdentifier(localeIdentifier) + }, + setIntegrationAttributes: async (attributes) => { await awaitConfigured() await SuperwallExpoModule.setIntegrationAttributes(attributes)