diff --git a/CHANGELOG.md b/CHANGELOG.md index f452e98b1..213da558f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## 3.0.0 + +### Updates +- Added embedded messaging functionality. This includes the ability to: + - Manually sync messages with `Iterable.embeddedManager.syncMessages()` + - Get messages with `Iterable.embeddedManager.getMessages([PLACEMENT_IDS])` + - Start an embedded session with `Iterable.embeddedManager.startSession()` + - End an embedded session with `Iterable.embeddedManager.endSession()` + - Start an embedded impression with + `Iterable.embeddedManager.startImpression(MESSAGE_ID, PLACEMENT_ID)` + - Pause an embedded impression with + `Iterable.embeddedManager.pauseImpression(MESSAGE_ID)` + - Handle embedded clicks with + `Iterable.embeddedManager.handleClick(message, buttonId, action)` +- Added out-of-the-box (OOTB) embedded views (embedded components) for rendering + embedded messages in your app: + - `IterableEmbeddedView` — renders a message by `IterableEmbeddedViewType` + (Banner, Card, or Notification) + - `IterableEmbeddedBanner`, `IterableEmbeddedCard`, and + `IterableEmbeddedNotification` — individual OOTB view components + - `IterableEmbeddedViewConfig` — optional styling configuration for OOTB views + ## 2.2.2 ### Updates - Added `baseline-browser-mapping` diff --git a/README.md b/README.md index d2f597b05..15f923c83 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,13 @@ View the [API documentation](https://iterable-react-native-sdk.netlify.app). ## Architecture Support -Iterable's React Native SDK supports [React Native's New +Iterable's React Native SDK requires [React Native's New Architecture](https://reactnative.dev/architecture/landing-page), including TurboModules and Fabric. -**IMPORTANT**: Iterable's React Native SDK supports React Native's Legacy Architecture, but it -is no longer actively maintained. Use at your own risk. - Notes: -- Ensure your app is configured for New Architecture per the React Native docs. +- Ensure your app has New Architecture enabled per the React Native docs (`newArchEnabled=true` on Android, `RCT_NEW_ARCH_ENABLED=1` on iOS). - The example app in this repository is configured with New Architecture enabled. ## Beta Versions diff --git a/android/build.gradle b/android/build.gradle index 6a3eb970b..83cc37e7d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -15,16 +15,9 @@ buildscript { } } -def isNewArchitectureEnabled() { - return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" -} - apply plugin: "com.android.library" apply plugin: "kotlin-android" - -if (isNewArchitectureEnabled()) { - apply plugin: "com.facebook.react" -} +apply plugin: "com.facebook.react" def getExtOrDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["RNIterable_" + name] @@ -34,32 +27,15 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNIterable_" + name]).toInteger() } -def supportsNamespace() { - def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') - def major = parsed[0].toInteger() - def minor = parsed[1].toInteger() - - // Namespace support was added in 7.3.0 - return (major == 7 && minor >= 3) || major >= 8 -} - android { - if (supportsNamespace()) { - namespace "com.iterable.reactnative" - - sourceSets { - main { - manifest.srcFile "src/main/AndroidManifestNew.xml" - } - } - } + namespace "com.iterable.reactnative" compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") - buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()) + buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true") } buildFeatures { @@ -81,16 +57,6 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } - sourceSets { - main { - if (isNewArchitectureEnabled()) { - java.srcDirs += ['src/newarch'] - } else { - java.srcDirs += ['src/oldarch'] - } - } - } - // Add this to match the Iterable SDK group ID group = "com.iterable" } @@ -108,4 +74,3 @@ dependencies { api "com.iterable:iterableapi:3.6.2" // api project(":iterableapi") // links to local android SDK repo rather than by release } - diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index fbed06f36..a2f47b605 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,2 @@ - + diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml deleted file mode 100644 index a2f47b605..000000000 --- a/android/src/main/AndroidManifestNew.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModule.java similarity index 100% rename from android/src/newarch/java/com/RNIterableAPIModule.java rename to android/src/main/java/com/iterable/reactnative/RNIterableAPIModule.java diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIPackage.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIPackage.java index 3fade361b..17d093d2c 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIPackage.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIPackage.java @@ -30,14 +30,13 @@ public ReactModuleInfoProvider getReactModuleInfoProvider() { @Override public Map getReactModuleInfos() { Map moduleInfos = new HashMap<>(); - boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; moduleInfos.put(RNIterableAPIModuleImpl.NAME, new ReactModuleInfo( RNIterableAPIModuleImpl.NAME, RNIterableAPIModuleImpl.NAME, false, // canOverrideExistingModule false, // needsEagerInit false, // isCxxModule - isTurboModule // isTurboModule + true // isTurboModule )); return moduleInfos; } diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java deleted file mode 100644 index ce0a6280c..000000000 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ /dev/null @@ -1,273 +0,0 @@ -package com.iterable.reactnative; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.facebook.react.bridge.Callback; -import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; -import com.facebook.react.bridge.ReactMethod; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.ReadableMap; - -public class RNIterableAPIModule extends ReactContextBaseJavaModule { - private final ReactApplicationContext reactContext; - private static RNIterableAPIModuleImpl moduleImpl; - - RNIterableAPIModule(ReactApplicationContext context) { - super(context); - this.reactContext = context; - if (moduleImpl == null) { - moduleImpl = new RNIterableAPIModuleImpl(reactContext); - } - } - - @NonNull - @Override - public String getName() { - return RNIterableAPIModuleImpl.NAME; - } - - @ReactMethod - public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, String version, Promise promise) { - moduleImpl.initializeWithApiKey(apiKey, configReadableMap, version, promise); - } - - @ReactMethod - public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, String version, String apiEndPointOverride, Promise promise) { - moduleImpl.initialize2WithApiKey(apiKey, configReadableMap, version, apiEndPointOverride, promise); - } - - @ReactMethod - public void setEmail(@Nullable String email, @Nullable String authToken) { - moduleImpl.setEmail(email, authToken); - } - - @ReactMethod - public void updateEmail(String email, @Nullable String authToken) { - moduleImpl.updateEmail(email, authToken); - } - - @ReactMethod - public void getEmail(Promise promise) { - moduleImpl.getEmail(promise); - } - - @ReactMethod - public void sampleMethod(String stringArgument, int numberArgument, Callback callback) { - moduleImpl.sampleMethod(stringArgument, numberArgument, callback); - } - - @ReactMethod - public void setUserId(@Nullable String userId, @Nullable String authToken) { - moduleImpl.setUserId(userId, authToken); - } - - @ReactMethod - public void updateUser(ReadableMap dataFields, boolean mergeNestedObjects) { - moduleImpl.updateUser(dataFields, mergeNestedObjects); - } - - @ReactMethod - public void getUserId(Promise promise) { - moduleImpl.getUserId(promise); - } - - @ReactMethod - public void trackEvent(String name, @Nullable ReadableMap dataFields) { - moduleImpl.trackEvent(name, dataFields); - } - - @ReactMethod - public void updateCart(ReadableArray items) { - moduleImpl.updateCart(items); - } - - @ReactMethod - public void trackPurchase(double total, ReadableArray items, @Nullable ReadableMap dataFields) { - moduleImpl.trackPurchase(total, items, dataFields); - } - - @ReactMethod - public void trackPushOpenWithCampaignId(double campaignId, @Nullable Double templateId, String messageId, boolean appAlreadyRunning, @Nullable ReadableMap dataFields) { - moduleImpl.trackPushOpenWithCampaignId(campaignId, templateId, messageId, appAlreadyRunning, dataFields); - } - - @ReactMethod - public void updateSubscriptions(@Nullable ReadableArray emailListIds, @Nullable ReadableArray unsubscribedChannelIds, @Nullable ReadableArray unsubscribedMessageTypeIds, @Nullable ReadableArray subscribedMessageTypeIds, double campaignId, double templateId) { - moduleImpl.updateSubscriptions(emailListIds, unsubscribedChannelIds, unsubscribedMessageTypeIds, subscribedMessageTypeIds, campaignId, templateId); - } - - @ReactMethod - public void showMessage(String messageId, boolean consume, final Promise promise) { - moduleImpl.showMessage(messageId, consume, promise); - } - - @ReactMethod - public void setReadForMessage(String messageId, boolean read) { - moduleImpl.setReadForMessage(messageId, read); - } - - @ReactMethod - public void removeMessage(String messageId, double location, double deleteSource) { - moduleImpl.removeMessage(messageId, location, deleteSource); - } - - @ReactMethod - public void getHtmlInAppContentForMessage(String messageId, final Promise promise) { - moduleImpl.getHtmlInAppContentForMessage(messageId, promise); - } - - @ReactMethod - public void getAttributionInfo(Promise promise) { - moduleImpl.getAttributionInfo(promise); - } - - @ReactMethod - public void setAttributionInfo(@Nullable ReadableMap attributionInfoReadableMap) { - moduleImpl.setAttributionInfo(attributionInfoReadableMap); - } - - @ReactMethod - public void getLastPushPayload(Promise promise) { - moduleImpl.getLastPushPayload(promise); - } - - @ReactMethod - public void disableDeviceForCurrentUser() { - moduleImpl.disableDeviceForCurrentUser(); - } - - @ReactMethod - public void handleAppLink(String uri, Promise promise) { - moduleImpl.handleAppLink(uri, promise); - } - - @ReactMethod - public void trackInAppOpen(String messageId, double location) { - moduleImpl.trackInAppOpen(messageId, location); - } - - @ReactMethod - public void trackInAppClick(String messageId, double location, String clickedUrl) { - moduleImpl.trackInAppClick(messageId, location, clickedUrl); - } - - @ReactMethod - public void trackInAppClose(String messageId, double location, double source, @Nullable String clickedUrl) { - moduleImpl.trackInAppClose(messageId, location, source, clickedUrl); - } - - @ReactMethod - public void inAppConsume(String messageId, double location, double source) { - moduleImpl.inAppConsume(messageId, location, source); - } - - @ReactMethod - public void getInAppMessages(Promise promise) { - moduleImpl.getInAppMessages(promise); - } - - @ReactMethod - public void getInboxMessages(Promise promise) { - moduleImpl.getInboxMessages(promise); - } - - @ReactMethod - public void getUnreadInboxMessagesCount(Promise promise) { - moduleImpl.getUnreadInboxMessagesCount(promise); - } - - @ReactMethod - public void setInAppShowResponse(double number) { - moduleImpl.setInAppShowResponse(number); - } - - @ReactMethod - public void setAutoDisplayPaused(final boolean paused) { - moduleImpl.setAutoDisplayPaused(paused); - } - - @ReactMethod - public void wakeApp() { - moduleImpl.wakeApp(); - } - - @ReactMethod - public void startSession(ReadableArray visibleRows) { - moduleImpl.startSession(visibleRows); - } - - @ReactMethod - public void endSession() { - moduleImpl.endSession(); - } - - @ReactMethod - public void updateVisibleRows(ReadableArray visibleRows) { - moduleImpl.updateVisibleRows(visibleRows); - } - - @ReactMethod - public void addListener(String eventName) { - moduleImpl.addListener(eventName); - } - - @ReactMethod - public void removeListeners(double count) { - moduleImpl.removeListeners(count); - } - - @ReactMethod - public void passAlongAuthToken(@Nullable String authToken) { - moduleImpl.passAlongAuthToken(authToken); - } - - @ReactMethod - public void pauseAuthRetries(boolean pauseRetry) { - moduleImpl.pauseAuthRetries(pauseRetry); - } - - @ReactMethod - public void syncEmbeddedMessages() { - moduleImpl.syncEmbeddedMessages(); - } - - @ReactMethod - public void startEmbeddedSession() { - moduleImpl.startEmbeddedSession(); - } - - @ReactMethod - public void endEmbeddedSession() { - moduleImpl.endEmbeddedSession(); - } - - @ReactMethod - public void startEmbeddedImpression(String messageId, double placementId) { - moduleImpl.startEmbeddedImpression(messageId, (int) placementId); - } - - @ReactMethod - public void pauseEmbeddedImpression(String messageId) { - moduleImpl.pauseEmbeddedImpression(messageId); - } - - @ReactMethod - public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { - moduleImpl.getEmbeddedMessages(placementIds, promise); - } - - @ReactMethod - public void trackEmbeddedClick(ReadableMap message, String buttonId, String clickedUrl) { - moduleImpl.trackEmbeddedClick(message, buttonId, clickedUrl); - } - - public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { - moduleImpl.sendEvent(eventName, eventData); - } - - void onInboxUpdated() { - moduleImpl.onInboxUpdated(); - } -} diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 49d326c85..21aaeb64b 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -27,11 +27,7 @@ android.useAndroidX=true # ./gradlew -PreactNativeArchitectures=x86_64 reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 -# Use this property to enable support to the new architecture. -# This will allow you to use TurboModules and the Fabric render in -# your application. You should enable this flag either if you want -# to write custom TurboModules/Fabric components OR use libraries that -# are providing them. +# Required: Iterable's React Native SDK uses TurboModules (New Architecture). newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. diff --git a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj index 768843497..75003bbce 100644 --- a/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj +++ b/example/ios/ReactNativeSdkExample.xcodeproj/project.pbxproj @@ -13,9 +13,9 @@ 77E3B5772EA71A4B001449CE /* IterableJwtGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5742EA71A4B001449CE /* IterableJwtGenerator.swift */; }; 77E3B5782EA71A4B001449CE /* JwtTokenModule.mm in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */; }; 77E3B5792EA71A4B001449CE /* JwtTokenModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */; }; + 7BABC8E95A78397FCECEA2F0 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FF85D92A8A84B3F33F67BC54 /* libPods-ReactNativeSdkExample.a */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; A3A40C20801B8F02005FA4C0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */; }; - DDD9C96E1785FEF10EEE61A5 /* libPods-ReactNativeSdkExample.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 627B7C7165CE568DB5CB8F50 /* libPods-ReactNativeSdkExample.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -38,8 +38,7 @@ 13B07FB71A68108700A75B9A /* ReactNativeSdkExample.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = ReactNativeSdkExample.entitlements; path = ReactNativeSdkExample/ReactNativeSdkExample.entitlements; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; 1FC6B09E65A7BD9F6864C5D8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = ReactNativeSdkExample/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 5D6CA6ECC26D5E3B8D778E91 /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; - 627B7C7165CE568DB5CB8F50 /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2056597565A00516C0B494AF /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; 779227312DFA3FB500D69EC0 /* ReactNativeSdkExample-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExample-Bridging-Header.h"; sourceTree = ""; }; 779227322DFA3FB500D69EC0 /* ReactNativeSdkExampleTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ReactNativeSdkExampleTests-Bridging-Header.h"; sourceTree = ""; }; 779227332DFA3FB500D69EC0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = ReactNativeSdkExample/AppDelegate.swift; sourceTree = ""; }; @@ -47,8 +46,9 @@ 77E3B5752EA71A4B001449CE /* JwtTokenModule.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = JwtTokenModule.mm; sourceTree = ""; }; 77E3B5762EA71A4B001449CE /* JwtTokenModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JwtTokenModule.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReactNativeSdkExample/LaunchScreen.storyboard; sourceTree = ""; }; - 97E816223ABF0AF81818FFC2 /* Pods-ReactNativeSdkExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.release.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.release.xcconfig"; sourceTree = ""; }; + A362BF549FC9765A361E0298 /* Pods-ReactNativeSdkExample.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReactNativeSdkExample.debug.xcconfig"; path = "Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample.debug.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + FF85D92A8A84B3F33F67BC54 /* libPods-ReactNativeSdkExample.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-ReactNativeSdkExample.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,7 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DDD9C96E1785FEF10EEE61A5 /* libPods-ReactNativeSdkExample.a in Frameworks */, + 7BABC8E95A78397FCECEA2F0 /* libPods-ReactNativeSdkExample.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -107,7 +107,7 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 627B7C7165CE568DB5CB8F50 /* libPods-ReactNativeSdkExample.a */, + FF85D92A8A84B3F33F67BC54 /* libPods-ReactNativeSdkExample.a */, ); name = Frameworks; sourceTree = ""; @@ -149,8 +149,8 @@ BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( - 5D6CA6ECC26D5E3B8D778E91 /* Pods-ReactNativeSdkExample.debug.xcconfig */, - 97E816223ABF0AF81818FFC2 /* Pods-ReactNativeSdkExample.release.xcconfig */, + A362BF549FC9765A361E0298 /* Pods-ReactNativeSdkExample.debug.xcconfig */, + 2056597565A00516C0B494AF /* Pods-ReactNativeSdkExample.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -180,13 +180,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "ReactNativeSdkExample" */; buildPhases = ( - A286DF5F63E46316F3012613 /* [CP] Check Pods Manifest.lock */, + 69F36DF6FC66D84AAE2C6376 /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - C291FA7A286A5A6C809214BF /* [CP] Embed Pods Frameworks */, - DA5584E2135C9BE15A390C81 /* [CP] Copy Pods Resources */, + 72215817BA6FBD07A244864A /* [CP] Embed Pods Frameworks */, + E112FEFD67C426D6B93F1F1B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -203,7 +203,8 @@ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1210; + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2640; TargetAttributes = { 00E356ED1AD99517003FC87E = { CreatedOnToolsVersion = 6.2; @@ -260,18 +261,18 @@ buildActionMask = 2147483647; files = ( ); - inputPaths = ( - "$(SRCROOT)/.xcode.env.local", - "$(SRCROOT)/.xcode.env", + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "Bundle React Native code and images"; - outputPaths = ( + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n"; }; - A286DF5F63E46316F3012613 /* [CP] Check Pods Manifest.lock */ = { + 69F36DF6FC66D84AAE2C6376 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -293,7 +294,7 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C291FA7A286A5A6C809214BF /* [CP] Embed Pods Frameworks */ = { + 72215817BA6FBD07A244864A /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -310,7 +311,7 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReactNativeSdkExample/Pods-ReactNativeSdkExample-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - DA5584E2135C9BE15A390C81 /* [CP] Copy Pods Resources */ = { + E112FEFD67C426D6B93F1F1B /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -365,7 +366,6 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; - DEVELOPMENT_TEAM = BP98Z28R86; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", @@ -397,7 +397,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; - DEVELOPMENT_TEAM = BP98Z28R86; INFOPLIST_FILE = ReactNativeSdkExampleTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -420,13 +419,12 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5D6CA6ECC26D5E3B8D778E91 /* Pods-ReactNativeSdkExample.debug.xcconfig */; + baseConfigurationReference = A362BF549FC9765A361E0298 /* Pods-ReactNativeSdkExample.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = ReactNativeSdkExample/ReactNativeSdkExample.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BP98Z28R86; ENABLE_BITCODE = NO; INFOPLIST_FILE = ReactNativeSdkExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -453,13 +451,12 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 97E816223ABF0AF81818FFC2 /* Pods-ReactNativeSdkExample.release.xcconfig */; + baseConfigurationReference = 2056597565A00516C0B494AF /* Pods-ReactNativeSdkExample.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = ReactNativeSdkExample/ReactNativeSdkExample.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = BP98Z28R86; INFOPLIST_FILE = ReactNativeSdkExample/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -515,8 +512,10 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CXX = ""; + DEVELOPMENT_TEAM = BP98Z28R86; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -577,6 +576,7 @@ ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; SWIFT_ENABLE_EXPLICIT_MODULES = NO; USE_HERMES = true; @@ -616,8 +616,10 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; CXX = ""; + DEVELOPMENT_TEAM = BP98Z28R86; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; diff --git a/example/ios/ReactNativeSdkExample.xcodeproj/xcshareddata/xcschemes/ReactNativeSdkExample.xcscheme b/example/ios/ReactNativeSdkExample.xcodeproj/xcshareddata/xcschemes/ReactNativeSdkExample.xcscheme index c3bb832ce..77088a682 100644 --- a/example/ios/ReactNativeSdkExample.xcodeproj/xcshareddata/xcschemes/ReactNativeSdkExample.xcscheme +++ b/example/ios/ReactNativeSdkExample.xcodeproj/xcshareddata/xcschemes/ReactNativeSdkExample.xcscheme @@ -1,6 +1,6 @@ ; } -// Try to use TurboModule if available (New Architecture) -// Fall back to NativeModules (Old Architecture) -const isTurboModuleEnabled = - '__turboModuleProxy' in global && - (global as Record).__turboModuleProxy != null; - let JwtTokenModule: Spec | null = null; try { - JwtTokenModule = isTurboModuleEnabled - ? TurboModuleRegistry.getEnforcing('JwtTokenModule') - : NativeModules.JwtTokenModule; + JwtTokenModule = TurboModuleRegistry.getEnforcing('JwtTokenModule'); } catch { - // Module not available - will throw error when used console.warn('JwtTokenModule native module is not available yet'); } -// Create a proxy that throws a helpful error when methods are called const createModuleProxy = (): Spec => { const handler: ProxyHandler = { get(_target, prop) { diff --git a/example/src/components/Embedded/Embedded.styles.ts b/example/src/components/Embedded/Embedded.styles.ts index bd712bacf..4a1d6d281 100644 --- a/example/src/components/Embedded/Embedded.styles.ts +++ b/example/src/components/Embedded/Embedded.styles.ts @@ -1,31 +1,85 @@ import { StyleSheet } from 'react-native'; -import { button, buttonText, container, hr, link, input, title, subtitle } from '../../constants'; -import { utilityColors, backgroundColors } from '../../constants/styles/colors'; +import { + backgroundColors, + button, + buttonText, + colors, + container, + hr, + input, + modalButton, + modalButtons, + modalContent, + modalOverlay, + subtitle, + title, + utilityColors, +} from '../../constants'; const styles = StyleSheet.create({ button, buttonText, - container, + compactUtilityButton: { + ...button, + paddingVertical: 8, + }, + compactUtilityButtonText: { + ...buttonText, + fontSize: 12, + lineHeight: 17, + }, + container: { + ...container, + marginHorizontal: 0, + marginTop: 0, + paddingHorizontal: 0, + }, + embeddedHr: { + ...hr, + marginVertical: 12, + }, embeddedSection: { display: 'flex', flexDirection: 'column', gap: 16, paddingHorizontal: 16, }, - embeddedTitle: { - fontSize: 16, - fontWeight: 'bold', - lineHeight: 20, + inputContainer: { + marginVertical: 10, }, - embeddedTitleContainer: { - display: 'flex', + jsonEditor: { + ...input, + fontSize: 12, + height: 220, + }, + modalButton, + modalButtons, + modalContent, + modalOverlay, + placementIdsInput: { + ...input, + flex: 1, + marginBottom: 0, + minWidth: 72, + }, + placementIdsLabel: { + flexShrink: 1, + fontSize: 14, + }, + placementIdsRow: { + alignItems: 'center', flexDirection: 'row', + gap: 8, + marginBottom: 8, }, - hr, - inputContainer: { - marginVertical: 10, + sessionButtonHalf: { + flex: 1, + }, + sessionButtonsRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 8, }, - link, subtitle: { ...subtitle, textAlign: 'center' }, text: { textAlign: 'center' }, textInput: input, @@ -33,6 +87,45 @@ const styles = StyleSheet.create({ utilitySection: { paddingHorizontal: 16, }, + viewTypeButton: { + alignItems: 'center', + borderColor: colors.brandCyan, + borderRadius: 8, + borderWidth: 2, + flex: 1, + paddingHorizontal: 8, + paddingVertical: 7, + }, + viewTypeButtonSelected: { + backgroundColor: colors.brandCyan, + }, + viewTypeButtonText: { + color: colors.brandCyan, + fontSize: 12, + fontWeight: '600', + }, + viewTypeButtonTextSelected: { + color: colors.backgroundPrimary, + }, + viewTypeButtons: { + flex: 1, + flexDirection: 'row', + gap: 8, + justifyContent: 'space-around', + minWidth: 0, + }, + viewTypeLabel: { + flexShrink: 0, + fontSize: 13, + fontWeight: '600', + }, + viewTypeSelector: { + alignItems: 'center', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginVertical: 12, + }, warningContainer: { backgroundColor: backgroundColors.backgroundWarningSubtle, borderLeftColor: utilityColors.warning100, diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index bbbab436f..fbcff222d 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -1,4 +1,6 @@ import { + Alert, + Modal, ScrollView, Text, TextInput, @@ -8,18 +10,29 @@ import { import { useCallback, useState } from 'react'; import { Iterable, - type IterableAction, type IterableEmbeddedMessage, + type IterableEmbeddedViewConfig, + IterableEmbeddedView, + IterableEmbeddedViewType, } from '@iterable/react-native-sdk'; import { SafeAreaView } from 'react-native-safe-area-context'; import styles from './Embedded.styles'; +const DEFAULT_CONFIG_JSON = `{ +}`; + export const Embedded = () => { const [placementIdsInput, setPlacementIdsInput] = useState(''); const [embeddedMessages, setEmbeddedMessages] = useState< IterableEmbeddedMessage[] >([]); + const [selectedViewType, setSelectedViewType] = + useState(IterableEmbeddedViewType.Banner); + const [viewConfig, setViewConfig] = + useState(null); + const [configEditorVisible, setConfigEditorVisible] = useState(false); + const [configJson, setConfigJson] = useState(DEFAULT_CONFIG_JSON); // Parse placement IDs from input const parsedPlacementIds = placementIdsInput @@ -52,38 +65,29 @@ export const Embedded = () => { }); }, [idsToFetch]); - const startEmbeddedImpression = useCallback( - (message: IterableEmbeddedMessage) => { - Iterable.embeddedManager.startImpression( - message.metadata.messageId, - // TODO: check if this should be changed to a number, as per the type - Number(message.metadata.placementId) - ); - }, - [] - ); + const openConfigEditor = useCallback(() => { + setConfigJson( + viewConfig ? JSON.stringify(viewConfig, null, 2) : DEFAULT_CONFIG_JSON + ); + setConfigEditorVisible(true); + }, [viewConfig]); - const pauseEmbeddedImpression = useCallback( - (message: IterableEmbeddedMessage) => { - Iterable.embeddedManager.pauseImpression(message.metadata.messageId); - }, - [] - ); + const applyConfig = useCallback(() => { + try { + const parsed = JSON.parse(configJson) as IterableEmbeddedViewConfig; + setViewConfig(parsed); + setConfigEditorVisible(false); + } catch { + Alert.alert('Error', 'Invalid JSON'); + } + }, [configJson]); - const handleClick = useCallback( - ( - message: IterableEmbeddedMessage, - buttonId: string | null, - action?: IterableAction | null - ) => { - Iterable.embeddedManager.handleClick(message, buttonId, action); - }, - [] - ); + const closeConfigEditor = useCallback(() => { + setConfigEditorVisible(false); + }, []); return ( - Embedded {!Iterable.embeddedManager.isEnabled && ( @@ -92,29 +96,107 @@ export const Embedded = () => { )} - - Enter placement IDs to fetch embedded messages - - - Sync messages - - - Start session - - - End session + + View: + + + setSelectedViewType(IterableEmbeddedViewType.Banner) + } + > + + Banner + + + setSelectedViewType(IterableEmbeddedViewType.Card)} + > + + Card + + + + setSelectedViewType(IterableEmbeddedViewType.Notification) + } + > + + Notification + + + + + + + Sync + + + Start session + + + End session + + + + Set view config - Placement IDs (comma-separated): - + + + Placement IDs{'\n'}(comma-separated): + + + Get messages for placement ids @@ -122,70 +204,50 @@ export const Embedded = () => { - + + + + + + + Cancel + + + Apply + + + + + + {embeddedMessages.map((message) => ( - - - Embedded message - - - startEmbeddedImpression(message)} - > - Start impression - - | - pauseEmbeddedImpression(message)} - > - Pause impression - - | - - handleClick(message, null, message.elements?.defaultAction) - } - > - Handle click - - - - metadata.messageId: {message.metadata.messageId} - metadata.placementId: {message.metadata.placementId} - elements.title: {message.elements?.title} - elements.body: {message.elements?.body} - - elements.defaultAction.data:{' '} - {message.elements?.defaultAction?.data} - - - elements.defaultAction.type:{' '} - {message.elements?.defaultAction?.type} - - {(message.elements?.buttons ?? []).map((button, buttonIndex) => ( - - - Button {buttonIndex + 1} - | - - handleClick(message, button.id, button.action) - } - > - Handle click - - - - button.id: {button.id} - button.title: {button.title} - button.action?.data: {button.action?.data} - button.action?.type: {button.action?.type} - - ))} - payload: {JSON.stringify(message.payload)} - + ))} diff --git a/example/src/constants/styles/index.ts b/example/src/constants/styles/index.ts index b8c3bac5e..225ee4903 100644 --- a/example/src/constants/styles/index.ts +++ b/example/src/constants/styles/index.ts @@ -2,5 +2,6 @@ export * from './colors'; export * from './containers'; export * from './formElements'; export * from './miscElements'; +export * from './modal'; export * from './shadows'; export * from './typography'; diff --git a/example/src/constants/styles/modal.ts b/example/src/constants/styles/modal.ts new file mode 100644 index 000000000..3c1ad791c --- /dev/null +++ b/example/src/constants/styles/modal.ts @@ -0,0 +1,43 @@ +import type { TextStyle, ViewStyle } from 'react-native'; +import { colors } from './colors'; + +export const modalTitle: TextStyle = { + fontSize: 18, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', +}; + +export const modalOverlay: ViewStyle = { + backgroundColor: 'rgba(0,0,0,0.5)', + flex: 1, + justifyContent: 'center', + padding: 20, +}; + +export const modalContent: ViewStyle = { + backgroundColor: colors.backgroundPrimary, + borderRadius: 12, + maxHeight: '80%', + padding: 16, +}; + +export const modalButtons: ViewStyle = { + flexDirection: 'row', + gap: 12, + justifyContent: 'flex-end', +}; + +export const modalButton: ViewStyle = { + flex: 1, +}; + +export const modalButtonText: TextStyle = { + color: colors.brandCyan, + fontSize: 14, + fontWeight: '600', +}; + +export const modalButtonTextSelected: TextStyle = { + color: colors.backgroundPrimary, +}; diff --git a/ios/RNIterableAPI/RNIterableAPI.h b/ios/RNIterableAPI/RNIterableAPI.h index a8b725452..7a5b20833 100644 --- a/ios/RNIterableAPI/RNIterableAPI.h +++ b/ios/RNIterableAPI/RNIterableAPI.h @@ -1,18 +1,10 @@ #import #import +#import +#import +#import +#import -#if RCT_NEW_ARCH_ENABLED - - #import - #import - #import - #import @interface RNIterableAPI : RCTEventEmitter -#else - #import -@interface RNIterableAPI : RCTEventEmitter - -#endif - @end diff --git a/ios/RNIterableAPI/RNIterableAPI.mm b/ios/RNIterableAPI/RNIterableAPI.mm index 025c4dc15..9493cf373 100644 --- a/ios/RNIterableAPI/RNIterableAPI.mm +++ b/ios/RNIterableAPI/RNIterableAPI.mm @@ -1,9 +1,6 @@ #import "RNIterableAPI.h" - -#if RCT_NEW_ARCH_ENABLED - #import "RNIterableAPISpec.h" - #import -#endif +#import "RNIterableAPISpec.h" +#import #import @@ -54,8 +51,6 @@ - (void)sendEventWithName:(NSString *_Nonnull)name result:(double)result { [self sendEventWithName:name body:@(result)]; } -#if RCT_NEW_ARCH_ENABLED - // MARK: - New Architecture functions exposed to JS - (void)startObserving { @@ -343,258 +338,4 @@ - (void)wakeApp { return std::make_shared(params); } -#else - -// MARK: - RCTBridgeModule integration for Legacy Architecture - -RCT_EXPORT_METHOD(startObserving) { - [(ReactIterableAPI *)_swiftAPI startObserving]; -} - -RCT_EXPORT_METHOD(stopObserving) { - [(ReactIterableAPI *)_swiftAPI stopObserving]; -} - -RCT_EXPORT_METHOD( - initializeWithApiKey : (NSString *)apiKey config : (NSDictionary *) - config version : (NSString *)version resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI initializeWithApiKey:apiKey - config:config - version:version - resolver:resolve - rejecter:reject]; -} - -RCT_EXPORT_METHOD( - initialize2WithApiKey : (NSString *)apiKey config : (NSDictionary *) - config version : (NSString *)version apiEndPointOverride : (NSString *) - apiEndPointOverride resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI initialize2WithApiKey:apiKey - config:config - version:version - apiEndPointOverride:apiEndPointOverride - resolver:resolve - rejecter:reject]; -} - -RCT_EXPORT_METHOD(setEmail : (NSString *_Nullable) - email authToken : (NSString *_Nullable)authToken) { - [_swiftAPI setEmail:email authToken:authToken]; -} - -RCT_EXPORT_METHOD(getEmail : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getEmail:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(setUserId : (NSString *_Nullable) - userId authToken : (NSString *_Nullable)authToken) { - [_swiftAPI setUserId:userId authToken:authToken]; -} - -RCT_EXPORT_METHOD(getUserId : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getUserId:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(setInAppShowResponse : (double)inAppShowResponse) { - [_swiftAPI setInAppShowResponse:inAppShowResponse]; -} - -RCT_EXPORT_METHOD(getInAppMessages : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getInAppMessages:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(getInboxMessages : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getInboxMessages:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(getUnreadInboxMessagesCount : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getUnreadInboxMessagesCount:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(showMessage : (NSString *)messageId consume : (BOOL) - consume resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI showMessage:messageId - consume:consume - resolver:resolve - rejecter:reject]; -} - -RCT_EXPORT_METHOD(removeMessage : (NSString *)messageId location : (double) - location source : (double)source) { - [_swiftAPI removeMessage:messageId location:location source:source]; -} - -RCT_EXPORT_METHOD(setReadForMessage : (NSString *)messageId read : (BOOL)read) { - [_swiftAPI setReadForMessage:messageId read:read]; -} - -RCT_EXPORT_METHOD(setAutoDisplayPaused : (BOOL)autoDisplayPaused) { - [_swiftAPI setAutoDisplayPaused:autoDisplayPaused]; -} - -RCT_EXPORT_METHOD(trackEvent : (NSString *)name dataFields : (NSDictionary *) - dataFields) { - [_swiftAPI trackEvent:name dataFields:dataFields]; -} - -RCT_EXPORT_METHOD( - trackPushOpenWithCampaignId : (double)campaignId templateId : (NSNumber *) - templateId messageId : (NSString *)messageId appAlreadyRunning : (BOOL) - appAlreadyRunning dataFields : (NSDictionary *)dataFields) { - [_swiftAPI trackPushOpenWithCampaignId:campaignId - templateId:templateId - messageId:messageId - appAlreadyRunning:appAlreadyRunning - dataFields:dataFields]; -} - -RCT_EXPORT_METHOD(trackInAppOpen : (NSString *)messageId location : (double) - location) { - [_swiftAPI trackInAppOpen:messageId location:location]; -} - -RCT_EXPORT_METHOD(trackInAppClick : (NSString *)messageId location : (double) - location clickedUrl : (NSString *)clickedUrl) { - [_swiftAPI trackInAppClick:messageId location:location clickedUrl:clickedUrl]; -} - -RCT_EXPORT_METHOD(trackInAppClose : (NSString *)messageId location : (double) - location source : (double)source clickedUrl : (NSString *) - clickedUrl) { - [_swiftAPI trackInAppClose:messageId - location:location - source:source - clickedUrl:clickedUrl]; -} - -RCT_EXPORT_METHOD(inAppConsume : (NSString *)messageId location : (double) - location source : (double)source) { - [_swiftAPI inAppConsume:messageId location:location source:source]; -} - -RCT_EXPORT_METHOD(updateCart : (NSArray *)items) { - [_swiftAPI updateCart:items]; -} - -RCT_EXPORT_METHOD(trackPurchase : (double)total items : (NSArray *) - items dataFields : (NSDictionary *)dataFields) { - [_swiftAPI trackPurchase:total items:items dataFields:dataFields]; -} - -RCT_EXPORT_METHOD(updateUser : (NSDictionary *)dataFields mergeNestedObjects : ( - BOOL)mergeNestedObjects) { - [_swiftAPI updateUser:dataFields mergeNestedObjects:mergeNestedObjects]; -} - -RCT_EXPORT_METHOD(updateEmail : (NSString *)email authToken : (NSString *) - authToken) { - [_swiftAPI updateEmail:email authToken:authToken]; -} - -RCT_EXPORT_METHOD(getAttributionInfo : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getAttributionInfo:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(setAttributionInfo : (NSDictionary *)attributionInfo) { - [_swiftAPI setAttributionInfo:attributionInfo]; -} - -RCT_EXPORT_METHOD(disableDeviceForCurrentUser) { - [_swiftAPI disableDeviceForCurrentUser]; -} - -RCT_EXPORT_METHOD(getLastPushPayload : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getLastPushPayload:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(getHtmlInAppContentForMessage : (NSString *) - messageId resolve : (RCTPromiseResolveBlock) - resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getHtmlInAppContentForMessage:messageId - resolver:resolve - rejecter:reject]; -} - -RCT_EXPORT_METHOD(handleAppLink : (NSString *)appLink resolve : ( - RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI handleAppLink:appLink resolver:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD( - updateSubscriptions : (NSArray *)emailListIds unsubscribedChannelIds : ( - NSArray *) - unsubscribedChannelIds unsubscribedMessageTypeIds : (NSArray *) - unsubscribedMessageTypeIds subscribedMessageTypeIds : (NSArray *) - subscribedMessageTypeIds campaignId : (double) - campaignId templateId : (double)templateId) { - [_swiftAPI updateSubscriptions:emailListIds - unsubscribedChannelIds:unsubscribedChannelIds - unsubscribedMessageTypeIds:unsubscribedMessageTypeIds - subscribedMessageTypeIds:subscribedMessageTypeIds - campaignId:campaignId - templateId:templateId]; -} - -RCT_EXPORT_METHOD(startSession : (NSArray *)visibleRows) { - [_swiftAPI startSession:visibleRows]; -} - -RCT_EXPORT_METHOD(endSession) { [_swiftAPI endSession]; } - -RCT_EXPORT_METHOD(updateVisibleRows : (NSArray *)visibleRows) { - [_swiftAPI updateVisibleRows:visibleRows]; -} - -RCT_EXPORT_METHOD(passAlongAuthToken : (NSString *_Nullable)authToken) { - [_swiftAPI passAlongAuthToken:authToken]; -} - -RCT_EXPORT_METHOD(pauseAuthRetries : (BOOL)pauseRetry) { - [_swiftAPI pauseAuthRetries:pauseRetry]; -} - -RCT_EXPORT_METHOD(startEmbeddedSession) { - [_swiftAPI startEmbeddedSession]; -} - -RCT_EXPORT_METHOD(endEmbeddedSession) { - [_swiftAPI endEmbeddedSession]; -} - -RCT_EXPORT_METHOD(syncEmbeddedMessages) { - [_swiftAPI syncEmbeddedMessages]; -} - -RCT_EXPORT_METHOD(getEmbeddedMessages : (NSArray *_Nullable)placementIds resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) { - [_swiftAPI getEmbeddedMessages:placementIds resolver:resolve rejecter:reject]; -} - -RCT_EXPORT_METHOD(startEmbeddedImpression : (NSString *)messageId placementId : (double)placementId) { - [_swiftAPI startEmbeddedImpression:messageId placementId:placementId]; -} - -RCT_EXPORT_METHOD(pauseEmbeddedImpression : (NSString *)messageId) { - [_swiftAPI pauseEmbeddedImpression:messageId]; -} - -RCT_EXPORT_METHOD(trackEmbeddedClick : (NSDictionary *)message buttonId : (NSString *_Nullable)buttonId clickedUrl : (NSString *_Nullable)clickedUrl) { - [_swiftAPI trackEmbeddedClick:message buttonId:buttonId clickedUrl:clickedUrl]; -} - -RCT_EXPORT_METHOD(wakeApp) { - // Placeholder function -- this method is only used in Android -} - -#endif - @end diff --git a/package.json b/package.json index 867daad6a..7aa03d441 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iterable/react-native-sdk", - "version": "2.2.2", + "version": "3.0.0", "description": "Iterable SDK for React Native.", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/src/api/index.ts b/src/api/index.ts index 9c327891b..4f0d64ff7 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,10 @@ import { NativeModules } from 'react-native'; -import BridgelessModule from './NativeRNIterableAPI'; -export const RNIterableAPI = BridgelessModule ?? NativeModules.RNIterableAPI; +import IterableTurboModule from './NativeRNIterableAPI'; +// Production: TurboModule from codegen (New Architecture). +// Jest: IterableTurboModule is undefined; tests provide MockRNIterableAPI via NativeModules. +const RNIterableAPI = IterableTurboModule ?? NativeModules.RNIterableAPI; + +export { RNIterableAPI }; export default RNIterableAPI; diff --git a/src/core/assets/index.ts b/src/core/assets/index.ts new file mode 100644 index 000000000..b2fe2a8a6 --- /dev/null +++ b/src/core/assets/index.ts @@ -0,0 +1,3 @@ +import IterableLogoGrey from './logo-grey.png'; + +export { IterableLogoGrey }; diff --git a/src/core/assets/logo-grey.png b/src/core/assets/logo-grey.png new file mode 100644 index 000000000..5c0d56a92 Binary files /dev/null and b/src/core/assets/logo-grey.png differ diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts new file mode 100644 index 000000000..45a2099ea --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.ts @@ -0,0 +1,89 @@ +import { StyleSheet, Platform } from 'react-native'; + +// See https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages#banners +export const IMAGE_HEIGHT = Platform.OS === 'android' ? 80 : 100; +export const IMAGE_WIDTH = Platform.OS === 'android' ? 80 : 100; +const SHADOW_COLOR_LIGHT = 'rgba(0, 0, 0, 0.06)'; +const SHADOW_COLOR_DARK = 'rgba(0, 0, 0, 0.08)'; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + alignItems: 'center', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + paddingTop: 4, + }, + button: { + borderRadius: 32, + gap: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + gap: 12, + width: '100%', + }, + buttonText: { + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + paddingHorizontal: 12, + paddingVertical: 8, + }, + container: { + alignItems: 'flex-start', + borderStyle: 'solid', + boxShadow: + `0 1px 1px 0 ${SHADOW_COLOR_LIGHT}, 0 0 2px 0 ${SHADOW_COLOR_LIGHT}, 0 0 1px 0 ${SHADOW_COLOR_DARK}`, + display: 'flex', + elevation: 1, + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + padding: 16, + shadowColor: SHADOW_COLOR_LIGHT, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.9, + shadowRadius: 2, + width: '100%', + }, + mediaContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + }, + mediaImage: { + borderRadius: 6, + borderStyle: 'solid', + borderWidth: 1, + height: IMAGE_HEIGHT, + paddingHorizontal: 0, + paddingVertical: 0, + width: IMAGE_WIDTH, + }, + textContainer: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + flexShrink: 1, + gap: 4, + width: '100%', + }, + title: { + fontSize: 16, + fontWeight: '700', + lineHeight: 16, + paddingBottom: 4, + }, +}); diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx new file mode 100644 index 000000000..aab2230fa --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.test.tsx @@ -0,0 +1,359 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; + +const mockHandleButtonClick = jest.fn(); +const mockHandleMessageClick = jest.fn(); + +jest.mock('../../hooks/useEmbeddedView', () => ({ + useEmbeddedView: jest.fn(), +})); + +const mockUseEmbeddedView = useEmbeddedView as jest.MockedFunction< + typeof useEmbeddedView +>; + +const defaultParsedStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderCornerRadius: 8, + borderWidth: 1, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#79347F', + titleTextColor: '#3D3A3B', + bodyTextColor: '#787174', +}; + +function mockUseEmbeddedViewReturn( + overrides: Partial> = {} +) { + mockUseEmbeddedView.mockReturnValue({ + parsedStyles: defaultParsedStyles, + handleButtonClick: mockHandleButtonClick, + handleMessageClick: mockHandleMessageClick, + media: { url: null, caption: null, shouldShow: false }, + ...overrides, + }); +} + +describe('IterableEmbeddedBanner', () => { + const baseMessage: IterableEmbeddedMessage = { + metadata: { + messageId: 'msg-1', + campaignId: 1, + placementId: 1, + }, + elements: { + title: 'Banner Title', + body: 'Banner body text.', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseEmbeddedViewReturn(); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { getByText } = render( + + ); + expect(getByText('Banner Title')).toBeTruthy(); + expect(getByText('Banner body text.')).toBeTruthy(); + }); + + it('should render title and body from message.elements', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + title: 'Custom Banner Title', + body: 'Custom banner body.', + }, + }; + const { getByText } = render( + + ); + expect(getByText('Custom Banner Title')).toBeTruthy(); + expect(getByText('Custom banner body.')).toBeTruthy(); + }); + + it('should apply parsedStyles to container and text', () => { + const customStyles = { + ...defaultParsedStyles, + backgroundColor: '#000000', + titleTextColor: '#ff0000', + bodyTextColor: '#00ff00', + }; + mockUseEmbeddedViewReturn({ parsedStyles: customStyles }); + + const { getByText, UNSAFE_getAllByType } = render( + + ); + + const views = UNSAFE_getAllByType('View' as any); + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + const container = views.find( + (v: any) => + v.props.style && + styleArray(v.props.style).some( + (sty: any) => sty && sty.backgroundColor === '#000000' + ) + ); + expect(container).toBeTruthy(); + expect(styleArray(container!.props.style)).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + backgroundColor: '#000000', + borderColor: customStyles.borderColor, + borderRadius: customStyles.borderCornerRadius, + borderWidth: customStyles.borderWidth, + }), + ]) + ); + + const title = getByText('Banner Title'); + const body = getByText('Banner body text.'); + expect(title.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#ff0000' }), + ]) + ); + expect(body.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#00ff00' }), + ]) + ); + }); + + it('should not render button container when message has no buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: undefined }, + }; + const { queryByText } = render( + + ); + expect(queryByText('Primary')).toBeNull(); + }); + + it('should not render button container when buttons array is empty', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: [] }, + }; + const { queryByText } = render( + + ); + expect(queryByText('Primary')).toBeNull(); + }); + }); + + describe('Buttons', () => { + const primaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-primary', + title: 'Primary', + action: { type: 'openUrl', data: 'https://example.com' }, + }; + const secondaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-secondary', + title: 'Secondary', + }; + + it('should render buttons when message has buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + expect(getByText('Primary')).toBeTruthy(); + expect(getByText('Secondary')).toBeTruthy(); + }); + + it('should apply primary and secondary button text colors from parsedStyles', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + const primaryText = getByText('Primary'); + const secondaryText = getByText('Secondary'); + expect(primaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.primaryBtnTextColor, + }), + ]) + ); + expect(secondaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.secondaryBtnTextColor, + }), + ]) + ); + }); + + it('should call handleButtonClick with correct button when button is pressed', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + fireEvent.press(getByText('Primary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(1); + expect(mockHandleButtonClick).toHaveBeenCalledWith(primaryButton); + + fireEvent.press(getByText('Secondary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(2); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith(secondaryButton); + }); + }); + + describe('Media', () => { + it('should not render media when media.shouldShow is false', () => { + const { UNSAFE_queryAllByType } = render( + + ); + const images = UNSAFE_queryAllByType('Image' as any); + expect(images.length).toBe(0); + }); + + it('should render media image when media.shouldShow is true', () => { + const media = { + url: 'https://example.com/image.png', + caption: 'Banner image', + shouldShow: true, + }; + mockUseEmbeddedViewReturn({ media }); + + const { UNSAFE_queryAllByType } = render( + + ); + + const images = UNSAFE_queryAllByType('Image' as any); + expect(images.length).toBeGreaterThan(0); + expect((images[0] as any).props.source.uri).toBe(media.url); + }); + }); + + describe('Message click', () => { + it('should call handleMessageClick when banner is pressed', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Banner Title')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('useEmbeddedView integration', () => { + it('should call useEmbeddedView with Banner viewType and props', () => { + const config = { backgroundColor: '#abc' } as any; + const onButtonClick = jest.fn(); + const onMessageClick = jest.fn(); + + render( + + ); + + expect(mockUseEmbeddedView).toHaveBeenCalledTimes(1); + expect(mockUseEmbeddedView).toHaveBeenCalledWith( + IterableEmbeddedViewType.Banner, + { + message: baseMessage, + config, + onButtonClick, + onMessageClick, + } + ); + }); + }); + + describe('Edge cases', () => { + it('should handle message with missing elements', () => { + const message: IterableEmbeddedMessage = { + metadata: baseMessage.metadata, + elements: undefined, + }; + const { queryByText } = render( + + ); + expect(queryByText('Banner Title')).toBeNull(); + expect(queryByText('Banner body text.')).toBeNull(); + }); + + it('should handle message with empty title and body without throwing', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { title: '', body: '' }, + }; + const { getAllByText } = render( + + ); + const emptyTextNodes = getAllByText(''); + expect(emptyTextNodes.length).toBeGreaterThanOrEqual(1); + }); + + it('should render multiple buttons and call handleButtonClick with correct button for each', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [ + { id: 'unique-id-1', title: 'First' }, + { id: 'unique-id-2', title: 'Second' }, + ], + }, + }; + const { getByText } = render( + + ); + expect(getByText('First')).toBeTruthy(); + expect(getByText('Second')).toBeTruthy(); + fireEvent.press(getByText('First')); + expect(mockHandleButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'unique-id-1', title: 'First' }) + ); + fireEvent.press(getByText('Second')); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'unique-id-2', title: 'Second' }) + ); + }); + }); +}); diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx new file mode 100644 index 000000000..8035b4b82 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx @@ -0,0 +1,146 @@ +import { + Image, + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, + PixelRatio, + Pressable, +} from 'react-native'; + +import { IterableEmbeddedViewType } from '../../enums'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import { + styles, + IMAGE_HEIGHT, + IMAGE_WIDTH, +} from './IterableEmbeddedBanner.styles'; + +/** + * The IterableEmbeddedBanner component is used to render a banner message. + * + * @param config - The config for the IterableEmbeddedBanner component. + * @param message - The message to render. + * @param onButtonClick - The function to call when a button is clicked. + * @param onMessageClick - The function to call when the message is clicked. + * @returns The IterableEmbeddedBanner component. + * + * @example + * ```tsx + * return ( + * + * ); + * ``` + */ +export const IterableEmbeddedBanner = ({ + config, + message, + onButtonClick, + onMessageClick, +}: IterableEmbeddedComponentProps) => { + const { parsedStyles, media, handleButtonClick, handleMessageClick } = + useEmbeddedView(IterableEmbeddedViewType.Banner, { + message, + config, + onButtonClick, + onMessageClick, + }); + + const buttons = message.elements?.buttons ?? []; + + return ( + + + + + + {message.elements?.title} + + + {message.elements?.body} + + + {media.shouldShow && ( + + {media.caption + + )} + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedBanner/index.ts b/src/embedded/components/IterableEmbeddedBanner/index.ts new file mode 100644 index 000000000..39ed47189 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/index.ts @@ -0,0 +1,2 @@ +export * from './IterableEmbeddedBanner'; +export { IterableEmbeddedBanner as default } from './IterableEmbeddedBanner'; diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts new file mode 100644 index 000000000..8f51e2ff7 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts @@ -0,0 +1,95 @@ +import { StyleSheet } from 'react-native'; + +export const IMAGE_HEIGHT = 230; +export const PLACEHOLDER_IMAGE_HEIGHT = 56; +export const PLACEHOLDER_IMAGE_WIDTH = 56; +const SHADOW_COLOR_LIGHT = 'rgba(0, 0, 0, 0.06)'; +const SHADOW_COLOR_DARK = 'rgba(0, 0, 0, 0.08)'; +const IMAGE_BACKGROUND_COLOR = '#F5F4F4'; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + gap: 24, + paddingBottom: 16, + paddingHorizontal: 16, + paddingTop: 12, + }, + button: { + borderRadius: 32, + gap: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + gap: 12, + width: '100%', + }, + buttonText: { + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + }, + container: { + alignItems: 'center', + borderStyle: 'solid', + boxShadow: + `0 1px 1px 0 ${SHADOW_COLOR_LIGHT}, 0 0 2px 0 ${SHADOW_COLOR_LIGHT}, 0 0 1px 0 ${SHADOW_COLOR_DARK}`, + display: 'flex', + elevation: 1, + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + overflow: 'hidden', + shadowColor: SHADOW_COLOR_LIGHT, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.9, + shadowRadius: 2, + width: '100%', + }, + mediaContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + backgroundColor: IMAGE_BACKGROUND_COLOR, + display: 'flex', + flexDirection: 'row', + height: IMAGE_HEIGHT, + }, + mediaContainerNoImage: { + alignItems: 'center', + justifyContent: 'center', + }, + mediaImage: { + height: IMAGE_HEIGHT, + paddingHorizontal: 0, + paddingVertical: 0, + width: '100%', + }, + mediaImagePlaceholder: { + height: PLACEHOLDER_IMAGE_HEIGHT, + opacity: 0.25, + width: PLACEHOLDER_IMAGE_WIDTH, + }, + textContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + title: { + fontSize: 18, + fontWeight: '700', + }, +}); diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.test.tsx b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.test.tsx new file mode 100644 index 000000000..c423595e9 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.test.tsx @@ -0,0 +1,371 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { IterableLogoGrey } from '../../../core/assets'; +import { + IMAGE_HEIGHT, + PLACEHOLDER_IMAGE_HEIGHT, + PLACEHOLDER_IMAGE_WIDTH, +} from './IterableEmbeddedCard.styles'; +import { IterableEmbeddedCard } from './IterableEmbeddedCard'; + +const mockHandleButtonClick = jest.fn(); +const mockHandleMessageClick = jest.fn(); + +jest.mock('../../hooks/useEmbeddedView', () => ({ + useEmbeddedView: jest.fn(), +})); + +const mockUseEmbeddedView = useEmbeddedView as jest.MockedFunction< + typeof useEmbeddedView +>; + +const defaultParsedStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderCornerRadius: 8, + borderWidth: 1, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#79347F', + titleTextColor: '#3D3A3B', + bodyTextColor: '#787174', +}; + +function mockUseEmbeddedViewReturn( + overrides: Partial> = {} +) { + mockUseEmbeddedView.mockReturnValue({ + parsedStyles: defaultParsedStyles, + handleButtonClick: mockHandleButtonClick, + handleMessageClick: mockHandleMessageClick, + media: { url: null, caption: null, shouldShow: false }, + ...overrides, + }); +} + +describe('IterableEmbeddedCard', () => { + const baseMessage: IterableEmbeddedMessage = { + metadata: { + messageId: 'msg-1', + campaignId: 1, + placementId: 1, + }, + elements: { + title: 'Card Title', + body: 'Card body text.', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseEmbeddedViewReturn(); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { getByText } = render(); + expect(getByText('Card Title')).toBeTruthy(); + expect(getByText('Card body text.')).toBeTruthy(); + }); + + it('should render title and body from message.elements', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + title: 'Custom Card Title', + body: 'Custom card body.', + }, + }; + const { getByText } = render(); + expect(getByText('Custom Card Title')).toBeTruthy(); + expect(getByText('Custom card body.')).toBeTruthy(); + }); + + it('should apply parsedStyles to container and text', () => { + const customStyles = { + ...defaultParsedStyles, + backgroundColor: '#000000', + titleTextColor: '#ff0000', + bodyTextColor: '#00ff00', + }; + mockUseEmbeddedViewReturn({ parsedStyles: customStyles }); + + const { getByText, UNSAFE_getAllByType } = render( + + ); + + const views = UNSAFE_getAllByType('View' as any); + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + const container = views.find( + (v: any) => + v.props.style && + styleArray(v.props.style).some( + (sty: any) => sty && sty.backgroundColor === '#000000' + ) + ); + expect(container).toBeTruthy(); + expect(styleArray(container!.props.style)).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + backgroundColor: '#000000', + borderColor: customStyles.borderColor, + borderRadius: customStyles.borderCornerRadius, + borderWidth: customStyles.borderWidth, + }), + ]) + ); + + const title = getByText('Card Title'); + const body = getByText('Card body text.'); + expect(title.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#ff0000' }), + ]) + ); + expect(body.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#00ff00' }), + ]) + ); + }); + + it('should not render button container when message has no buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: undefined }, + }; + const { queryByText } = render(); + expect(queryByText('Primary')).toBeNull(); + }); + + it('should not render button container when buttons array is empty', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: [] }, + }; + const { queryByText } = render(); + expect(queryByText('Primary')).toBeNull(); + }); + }); + + describe('Buttons', () => { + const primaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-primary', + title: 'Primary', + action: { type: 'openUrl', data: 'https://example.com' }, + }; + const secondaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-secondary', + title: 'Secondary', + }; + + it('should render buttons when message has buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render(); + expect(getByText('Primary')).toBeTruthy(); + expect(getByText('Secondary')).toBeTruthy(); + }); + + it('should apply primary and secondary button text colors from parsedStyles', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render(); + + const primaryText = getByText('Primary'); + const secondaryText = getByText('Secondary'); + expect(primaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.primaryBtnTextColor, + }), + ]) + ); + expect(secondaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.secondaryBtnTextColor, + }), + ]) + ); + }); + + it('should call handleButtonClick with correct button when button is pressed', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render(); + + fireEvent.press(getByText('Primary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(1); + expect(mockHandleButtonClick).toHaveBeenCalledWith(primaryButton); + + fireEvent.press(getByText('Secondary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(2); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith(secondaryButton); + }); + }); + + describe('Media', () => { + it('should render placeholder image when media.shouldShow is false', () => { + const { UNSAFE_getAllByType } = render( + + ); + + const images = UNSAFE_getAllByType('Image' as any); + expect(images.length).toBeGreaterThan(0); + + const image = images[0] as any; + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + + expect(styleArray(image.props.style)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + height: PLACEHOLDER_IMAGE_HEIGHT, + width: PLACEHOLDER_IMAGE_WIDTH, + }), + ]) + ); + expect(image.props.source).toBe(IterableLogoGrey); + }); + + it('should render media image when media.shouldShow is true', () => { + const media = { + url: 'https://example.com/image.png', + caption: 'Card image', + shouldShow: true, + }; + mockUseEmbeddedViewReturn({ media }); + + const { UNSAFE_getAllByType } = render( + + ); + + const images = UNSAFE_getAllByType('Image' as any); + expect(images.length).toBeGreaterThan(0); + + const image = images[0] as any; + expect(image.props.source.uri).toBe(media.url); + + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + expect(styleArray(image.props.style)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + height: IMAGE_HEIGHT, + }), + ]) + ); + }); + }); + + describe('Message click', () => { + it('should call handleMessageClick when card is pressed', () => { + const { getByText } = render(); + + fireEvent.press(getByText('Card Title')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('useEmbeddedView integration', () => { + it('should call useEmbeddedView with Card viewType and props', () => { + const config = { backgroundColor: '#abc' } as any; + const onButtonClick = jest.fn(); + const onMessageClick = jest.fn(); + + render( + + ); + + expect(mockUseEmbeddedView).toHaveBeenCalledTimes(1); + expect(mockUseEmbeddedView).toHaveBeenCalledWith( + IterableEmbeddedViewType.Card, + { + message: baseMessage, + config, + onButtonClick, + onMessageClick, + } + ); + }); + }); + + describe('Edge cases', () => { + it('should handle message with missing elements', () => { + const message: IterableEmbeddedMessage = { + metadata: baseMessage.metadata, + elements: undefined, + }; + const { queryByText } = render(); + expect(queryByText('Card Title')).toBeNull(); + expect(queryByText('Card body text.')).toBeNull(); + }); + + it('should handle message with empty title and body without throwing', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { title: '', body: '' }, + }; + const { getAllByText } = render( + + ); + const emptyTextNodes = getAllByText(''); + expect(emptyTextNodes.length).toBeGreaterThanOrEqual(1); + }); + + it('should render multiple buttons and call handleButtonClick with correct button for each', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [ + { id: 'unique-id-1', title: 'First' }, + { id: 'unique-id-2', title: 'Second' }, + ], + }, + }; + const { getByText } = render(); + expect(getByText('First')).toBeTruthy(); + expect(getByText('Second')).toBeTruthy(); + fireEvent.press(getByText('First')); + expect(mockHandleButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'unique-id-1', title: 'First' }) + ); + fireEvent.press(getByText('Second')); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'unique-id-2', title: 'Second' }) + ); + }); + }); +}); + diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx new file mode 100644 index 000000000..1f450be7d --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx @@ -0,0 +1,143 @@ +import { + Image, + PixelRatio, + Pressable, + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { IterableLogoGrey } from '../../../core/assets'; +import { IterableEmbeddedViewType } from '../../enums'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import { IMAGE_HEIGHT, styles } from './IterableEmbeddedCard.styles'; + +/** + * The IterableEmbeddedCard component is used to render a card message. + * + * @param config - The config for the IterableEmbeddedCard component. + * @param message - The message to render. + * @param onButtonClick - The function to call when a button is clicked. + * @param onMessageClick - The function to call when the message is clicked. + * @returns The IterableEmbeddedCard component. + * + * @example + * ```tsx + * return ( + * + * ); + * ``` + */ +export const IterableEmbeddedCard = ({ + config, + message, + onButtonClick = () => {}, + onMessageClick = () => {}, +}: IterableEmbeddedComponentProps) => { + const { handleButtonClick, handleMessageClick, media, parsedStyles } = + useEmbeddedView(IterableEmbeddedViewType.Card, { + message, + config, + onButtonClick, + onMessageClick, + }); + const buttons = message?.elements?.buttons ?? []; + + return ( + handleMessageClick()}> + + + {media.caption + + + + + {message.elements?.title} + + + {message.elements?.body} + + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedCard/index.ts b/src/embedded/components/IterableEmbeddedCard/index.ts new file mode 100644 index 000000000..97ca487ef --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/index.ts @@ -0,0 +1,2 @@ +export * from './IterableEmbeddedCard'; +export { IterableEmbeddedCard as default } from './IterableEmbeddedCard'; diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts new file mode 100644 index 000000000..24c2ca7ed --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts @@ -0,0 +1,71 @@ +import { StyleSheet } from 'react-native'; + +const SHADOW_COLOR = 'rgba(0, 0, 0, 0.06)'; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + flexShrink: 1, + gap: 4, + width: '100%', + }, + button: { + alignItems: 'center', + alignSelf: 'flex-start', + borderRadius: 32, + flexShrink: 1, + gap: 8, + justifyContent: 'center', + maxWidth: '100%', + minWidth: 0, + paddingHorizontal: 12, + paddingVertical: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + width: '100%', + }, + buttonText: { + flexShrink: 1, + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + maxWidth: '100%', + textAlign: 'center', + }, + container: { + alignItems: 'flex-start', + borderStyle: 'solid', + boxShadow: + `0 1px 1px 0 ${SHADOW_COLOR}, 0 0 2px 0 ${SHADOW_COLOR}, 0 0 1px 0 ${SHADOW_COLOR}`, + display: 'flex', + elevation: 1, + flexDirection: 'column', + gap: 8, + justifyContent: 'center', + padding: 16, + shadowColor: SHADOW_COLOR, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.9, + shadowRadius: 2, + width: '100%', + }, + title: { + fontSize: 16, + fontWeight: '700', + lineHeight: 24, + }, +}); diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx new file mode 100644 index 000000000..3619cfc4f --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx @@ -0,0 +1,334 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; + +const mockHandleButtonClick = jest.fn(); +const mockHandleMessageClick = jest.fn(); + +jest.mock('../../hooks/useEmbeddedView', () => ({ + useEmbeddedView: jest.fn(), +})); + +const mockUseEmbeddedView = useEmbeddedView as jest.MockedFunction< + typeof useEmbeddedView +>; + +const defaultParsedStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderCornerRadius: 8, + borderWidth: 1, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#79347F', + titleTextColor: '#3D3A3B', + bodyTextColor: '#787174', +}; + +function mockUseEmbeddedViewReturn(overrides: Partial> = {}) { + mockUseEmbeddedView.mockReturnValue({ + parsedStyles: defaultParsedStyles, + handleButtonClick: mockHandleButtonClick, + handleMessageClick: mockHandleMessageClick, + media: { url: null, caption: null, shouldShow: false }, + ...overrides, + }); +} + +describe('IterableEmbeddedNotification', () => { + const baseMessage: IterableEmbeddedMessage = { + metadata: { + messageId: 'msg-1', + campaignId: 1, + placementId: 1, + }, + elements: { + title: 'Notification Title', + body: 'Notification body text.', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseEmbeddedViewReturn(); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { getByText } = render( + + ); + expect(getByText('Notification Title')).toBeTruthy(); + expect(getByText('Notification body text.')).toBeTruthy(); + }); + + it('should render title and body from message.elements', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + title: 'Custom Title', + body: 'Custom body content.', + }, + }; + const { getByText } = render( + + ); + expect(getByText('Custom Title')).toBeTruthy(); + expect(getByText('Custom body content.')).toBeTruthy(); + }); + + it('should apply parsedStyles to container and text', () => { + const customStyles = { + ...defaultParsedStyles, + backgroundColor: '#000000', + titleTextColor: '#ff0000', + bodyTextColor: '#00ff00', + }; + mockUseEmbeddedViewReturn({ parsedStyles: customStyles }); + + const { getByText, UNSAFE_getAllByType } = render( + + ); + + const views = UNSAFE_getAllByType('View' as any); + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + const container = views.find( + (v: any) => + v.props.style && + styleArray(v.props.style).some( + (sty: any) => sty && sty.backgroundColor === '#000000' + ) + ); + expect(container).toBeTruthy(); + expect(styleArray(container!.props.style)).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + backgroundColor: '#000000', + borderColor: customStyles.borderColor, + borderRadius: customStyles.borderCornerRadius, + borderWidth: customStyles.borderWidth, + }), + ]) + ); + + const title = getByText('Notification Title'); + const body = getByText('Notification body text.'); + expect(title.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#ff0000' }), + ]) + ); + expect(body.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#00ff00' }), + ]) + ); + }); + + it('should not render button container when message has no buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: undefined }, + }; + const { queryByText } = render( + + ); + expect(queryByText('CTA')).toBeNull(); + }); + + it('should not render button container when buttons array is empty', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: [] }, + }; + const { queryByText } = render( + + ); + expect(queryByText('Primary')).toBeNull(); + }); + }); + + describe('Buttons', () => { + const primaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-primary', + title: 'Primary', + action: { type: 'openUrl', data: 'https://example.com' }, + }; + const secondaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-secondary', + title: 'Secondary', + }; + + it('should render buttons when message has buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + expect(getByText('Primary')).toBeTruthy(); + expect(getByText('Secondary')).toBeTruthy(); + }); + + it('should apply primary and secondary button text colors from parsedStyles', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + const primaryText = getByText('Primary'); + const secondaryText = getByText('Secondary'); + expect(primaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.primaryBtnTextColor, + }), + ]) + ); + expect(secondaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.secondaryBtnTextColor, + }), + ]) + ); + }); + + it('should call handleButtonClick with correct button when button is pressed', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + fireEvent.press(getByText('Primary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(1); + expect(mockHandleButtonClick).toHaveBeenCalledWith(primaryButton); + + fireEvent.press(getByText('Secondary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(2); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith(secondaryButton); + }); + }); + + describe('Message click', () => { + it('should call handleMessageClick when message area is pressed', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Notification Title')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + + mockHandleMessageClick.mockClear(); + fireEvent.press(getByText('Notification body text.')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('useEmbeddedView integration', () => { + it('should call useEmbeddedView with Notification viewType and props', () => { + const config = { backgroundColor: '#abc' } as any; + const onButtonClick = jest.fn(); + const onMessageClick = jest.fn(); + + render( + + ); + + expect(mockUseEmbeddedView).toHaveBeenCalledTimes(1); + expect(mockUseEmbeddedView).toHaveBeenCalledWith( + IterableEmbeddedViewType.Notification, + { + message: baseMessage, + config, + onButtonClick, + onMessageClick, + } + ); + }); + }); + + describe('Edge cases', () => { + it('should handle message with missing elements', () => { + const message: IterableEmbeddedMessage = { + metadata: baseMessage.metadata, + elements: undefined, + }; + const { queryByText } = render( + + ); + expect(queryByText('Notification Title')).toBeNull(); + expect(queryByText('Notification body text.')).toBeNull(); + }); + + it('should handle message with empty title and body without throwing', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { title: '', body: '' }, + }; + const { getAllByText } = render( + + ); + const emptyTextNodes = getAllByText(''); + expect(emptyTextNodes.length).toBeGreaterThanOrEqual(1); + }); + + it('should render multiple buttons and call handleButtonClick with correct button for each', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [ + { id: 'unique-id-1', title: 'First' }, + { id: 'unique-id-2', title: 'Second' }, + ], + }, + }; + const { getByText } = render( + + ); + expect(getByText('First')).toBeTruthy(); + expect(getByText('Second')).toBeTruthy(); + fireEvent.press(getByText('First')); + expect(mockHandleButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'unique-id-1', title: 'First' }) + ); + fireEvent.press(getByText('Second')); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'unique-id-2', title: 'Second' }) + ); + }); + }); +}); diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx new file mode 100644 index 000000000..f713b6d60 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx @@ -0,0 +1,118 @@ +import { + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, + Pressable, +} from 'react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import { styles } from './IterableEmbeddedNotification.styles'; + +/** + * The IterableEmbeddedNotification component is used to render a notification message. + * + * @param config - The config for the IterableEmbeddedNotification component. + * @param message - The message to render. + * @param onButtonClick - The function to call when a button is clicked. + * @param onMessageClick - The function to call when the message is clicked. + * @returns The IterableEmbeddedNotification component. + * + * @example + * ```tsx + * return ( + * + * ); + * ``` + */ +export const IterableEmbeddedNotification = ({ + config, + message, + onButtonClick, + onMessageClick, +}: IterableEmbeddedComponentProps) => { + const { parsedStyles, handleButtonClick, handleMessageClick } = + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message, + config, + onButtonClick, + onMessageClick, + }); + + const buttons = message.elements?.buttons ?? []; + + return ( + handleMessageClick()}> + + + + {message.elements?.title} + + + {message.elements?.body} + + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedNotification/index.ts b/src/embedded/components/IterableEmbeddedNotification/index.ts new file mode 100644 index 000000000..3a25fd8ee --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/index.ts @@ -0,0 +1,2 @@ +export * from './IterableEmbeddedNotification'; +export { IterableEmbeddedNotification as default } from './IterableEmbeddedNotification'; diff --git a/src/embedded/components/IterableEmbeddedView.test.tsx b/src/embedded/components/IterableEmbeddedView.test.tsx new file mode 100644 index 000000000..4bcc47dcf --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView.test.tsx @@ -0,0 +1,373 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; +import { IterableEmbeddedView } from './IterableEmbeddedView'; +import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; +import { IterableEmbeddedCard } from './IterableEmbeddedCard'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; + +// Mock the child components +jest.mock('./IterableEmbeddedBanner', () => ({ + IterableEmbeddedBanner: jest.fn(() => null), +})); + +jest.mock('./IterableEmbeddedCard', () => ({ + IterableEmbeddedCard: jest.fn(() => null), +})); + +jest.mock('./IterableEmbeddedNotification', () => ({ + IterableEmbeddedNotification: jest.fn(() => null), +})); + +describe('IterableEmbeddedView', () => { + const mockMessage = { + metadata: { + messageId: 'test-message-123', + campaignId: 123456, + placementId: 'test-placement', + }, + elements: { + title: 'Test Title', + body: 'Test Body', + }, + } as any; + + const mockConfig = { + backgroundColor: '#FFFFFF', + borderRadius: 8, + } as any; + + const mockOnButtonClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('View Type Rendering', () => { + it('should render IterableEmbeddedCard when viewType is Card', () => { + render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render IterableEmbeddedNotification when viewType is Notification', () => { + render( + + ); + + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + }); + + it('should render IterableEmbeddedBanner when viewType is Banner', () => { + render( + + ); + + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render null for invalid viewType', () => { + render( + + ); + + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + + it('should render null for undefined viewType', () => { + render( + + ); + + expect(IterableEmbeddedCard).not.toHaveBeenCalled(); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + expect(IterableEmbeddedNotification).not.toHaveBeenCalled(); + }); + }); + + describe('Props Passing', () => { + it('should pass message prop to Card component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass message prop to Banner component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedBanner as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass message prop to Notification component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedNotification as jest.Mock).mock + .calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should pass config prop to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: mockConfig, + }); + }); + + it('should pass onButtonClick prop to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + onButtonClick: mockOnButtonClick, + }); + }); + + it('should pass all props to child component', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: mockConfig, + onButtonClick: mockOnButtonClick, + }); + }); + }); + + describe('Component Memoization', () => { + it('should memoize component selection based on viewType', () => { + const { rerender } = render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + + // Re-render with same viewType but different message + const newMessage = { + ...mockMessage, + metadata: { + ...mockMessage.metadata, + messageId: 'different-id', + }, + }; + + rerender( + + ); + + // Should still render Card component (memoization means same component reference) + // Card should be called again with new props + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(2); + const lastCallArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[1][0]; + expect(lastCallArgs).toMatchObject({ + message: newMessage, + }); + }); + + it('should update component when viewType changes', () => { + const { rerender } = render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + expect(IterableEmbeddedBanner).not.toHaveBeenCalled(); + + // Re-render with different viewType + rerender( + + ); + + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + // Card was called only once (from initial render) + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + }); + }); + + describe('Edge Cases', () => { + it('should handle null config gracefully', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: null, + }); + }); + + it('should handle undefined config gracefully', () => { + render( + + ); + + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + config: undefined, + }); + }); + + it('should handle missing onButtonClick gracefully', () => { + render( + + ); + + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + const callArgs = (IterableEmbeddedCard as jest.Mock).mock.calls[0][0]; + expect(callArgs).toMatchObject({ + message: mockMessage, + }); + }); + + it('should handle numeric viewType values correctly', () => { + // Test with numeric value 0 (Banner) + render(); + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + // Test with numeric value 1 (Card) + render(); + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + // Test with numeric value 2 (Notification) + render(); + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + }); + }); + + describe('Component Type Verification', () => { + it('should render correct component type for each enum value', () => { + // Verify Banner enum value + const bannerResult = render( + + ); + expect(IterableEmbeddedBanner).toHaveBeenCalledTimes(1); + bannerResult.unmount(); + + jest.clearAllMocks(); + + // Verify Card enum value + const cardResult = render( + + ); + expect(IterableEmbeddedCard).toHaveBeenCalledTimes(1); + cardResult.unmount(); + + jest.clearAllMocks(); + + // Verify Notification enum value + render( + + ); + expect(IterableEmbeddedNotification).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/embedded/components/IterableEmbeddedView.tsx b/src/embedded/components/IterableEmbeddedView.tsx new file mode 100644 index 000000000..47f3ca304 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView.tsx @@ -0,0 +1,115 @@ +import { useMemo } from 'react'; + +import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; +import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; +import { IterableEmbeddedCard } from './IterableEmbeddedCard'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; + +/** + * The props for the IterableEmbeddedView component. + */ +export interface IterableEmbeddedViewProps + extends IterableEmbeddedComponentProps { + /** The type of view to render. */ + viewType: IterableEmbeddedViewType; +} + +/** + * + * @param viewType - The type of view to render. + * @param message - The message to render. + * @param config - The config for the IterableEmbeddedView component, most likely used to style the view. + * @param onButtonClick - The function to call when a button is clicked. + * @param onMessageClick - The function to call when the message is clicked. + * @returns The IterableEmbeddedView component. + * + * This component is used to render the following pre-created, customizable + * message displays included with Iterables RN SDK: cards, banners, and + * notifications. + * + * @example + * ```tsx + * import { + * IterableAction, + * IterableEmbeddedView, + * IterableEmbeddedViewType, + * type IterableEmbeddedMessage, + * type IterableEmbeddedMessageElementsButton, + * } from '@iterable/react-native-sdk'; + * + * // See `IterableEmbeddedViewType` for available view types. + * const viewType = IterableEmbeddedViewType.Card; + * + * // Messages usually come from the embedded manager. `placementIds` is `number[] | null` + * // (use `null` to load messages for all placements), for example: + * // Iterable.embeddedManager.getMessages([101, 102]).then((messages) => { ... }); + * const message: IterableEmbeddedMessage = { + * metadata: { + * messageId: 'test-message-123', + * placementId: 101, + * }, + * elements: { + * title: 'Test Title', + * body: 'Test Body', + * buttons: [ + * { + * id: 'button-1', + * title: 'Button 1', + * action: new IterableAction('openUrl', 'https://example.com/one'), + * }, + * { + * id: 'button-2', + * title: 'Button 2', + * action: new IterableAction('openUrl', 'https://example.com/two'), + * }, + * ], + * }, + * }; + * + * // The config is used to style the component. + * // See `IterableEmbeddedViewConfig` for available config options. + * const config = { backgroundColor: '#FFFFFF', borderCornerRadius: 8 }; + * + * // `onButtonClick` will be called when a button is clicked. + * // This callback allows you to add custom logic in addition to the SDK's default handling. + * const onButtonClick = (button: IterableEmbeddedMessageElementsButton) => { + * console.log('Button clicked', button.id, button.title, button.action); + * }; + * + * // `onMessageClick` will be called when the message is clicked anywhere outside of a button. + * // If a default action is set, it will be handled prior to this callback. + * const onMessageClick = () => { + * console.log('Message clicked'); + * }; + * + * return ( + * + * ); + * ``` + */ +export const IterableEmbeddedView = ({ + viewType, + ...props +}: IterableEmbeddedViewProps) => { + const Cmp = useMemo(() => { + switch (viewType) { + case IterableEmbeddedViewType.Card: + return IterableEmbeddedCard; + case IterableEmbeddedViewType.Notification: + return IterableEmbeddedNotification; + case IterableEmbeddedViewType.Banner: + return IterableEmbeddedBanner; + default: + return null; + } + }, [viewType]); + + return Cmp ? : null; +}; diff --git a/src/embedded/components/index.ts b/src/embedded/components/index.ts new file mode 100644 index 000000000..15af78aba --- /dev/null +++ b/src/embedded/components/index.ts @@ -0,0 +1,4 @@ +export * from './IterableEmbeddedBanner'; +export * from './IterableEmbeddedCard'; +export * from './IterableEmbeddedNotification'; +export * from './IterableEmbeddedView'; diff --git a/src/embedded/constants/embeddedViewDefaults.ts b/src/embedded/constants/embeddedViewDefaults.ts new file mode 100644 index 000000000..10fa85bf7 --- /dev/null +++ b/src/embedded/constants/embeddedViewDefaults.ts @@ -0,0 +1,72 @@ +export const embeddedBackgroundColors = { + notification: '#ffffff', + card: '#ffffff', + banner: '#ffffff', +}; + +export const embeddedBorderColors = { + notification: '#E0DEDF', + card: '#E0DEDF', + banner: '#E0DEDF', +}; + +export const embeddedPrimaryBtnBackgroundColors = { + notification: '#6A266D', + card: 'transparent', + banner: '#6A266D', +}; + +export const embeddedPrimaryBtnTextColors = { + notification: '#ffffff', + card: '#79347F', + banner: '#ffffff', +}; + +export const embeddedSecondaryBtnBackgroundColors = { + notification: 'transparent', + card: 'transparent', + banner: 'transparent', +}; + +export const embeddedSecondaryBtnTextColors = { + notification: '#79347F', + card: '#79347F', + banner: '#79347F', +}; + +export const embeddedTitleTextColors = { + notification: '#3D3A3B', + card: '#3D3A3B', + banner: '#3D3A3B', +}; + +export const embeddedBodyTextColors = { + notification: '#787174', + card: '#787174', + banner: '#787174', +}; + +export const embeddedBorderRadius = { + notification: 8, + card: 6, + banner: 8, +}; + +export const embeddedBorderWidth = { + notification: 1, + card: 1, + banner: 1, +}; + +export const embeddedStyles = { + backgroundColor: embeddedBackgroundColors, + bodyText: embeddedBodyTextColors, + borderColor: embeddedBorderColors, + borderCornerRadius: embeddedBorderRadius, + borderWidth: embeddedBorderWidth, + primaryBtnBackgroundColor: embeddedPrimaryBtnBackgroundColors, + primaryBtnTextColor: embeddedPrimaryBtnTextColors, + secondaryBtnBackground: embeddedSecondaryBtnBackgroundColors, + secondaryBtnTextColor: embeddedSecondaryBtnTextColors, + titleText: embeddedTitleTextColors, +}; diff --git a/src/embedded/enums/IterableEmbeddedViewType.ts b/src/embedded/enums/IterableEmbeddedViewType.ts new file mode 100644 index 000000000..89873120c --- /dev/null +++ b/src/embedded/enums/IterableEmbeddedViewType.ts @@ -0,0 +1,17 @@ +/** + * The view type for an embedded message. + */ +export enum IterableEmbeddedViewType { + /** + * [Banner](https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages#banners) Out of the Box (OOTB) view. + */ + Banner = 0, + /** + * [Card](https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages#cards) Out of the Box (OOTB) view. + */ + Card = 1, + /** + * [Notification](https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages#notifications) Out of the Box (OOTB) view. + */ + Notification = 2, +} diff --git a/src/embedded/enums/index.ts b/src/embedded/enums/index.ts new file mode 100644 index 000000000..511ad021b --- /dev/null +++ b/src/embedded/enums/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedViewType'; diff --git a/src/embedded/hooks/index.ts b/src/embedded/hooks/index.ts new file mode 100644 index 000000000..cbca753d9 --- /dev/null +++ b/src/embedded/hooks/index.ts @@ -0,0 +1 @@ +export * from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView/getMedia.test.ts b/src/embedded/hooks/useEmbeddedView/getMedia.test.ts new file mode 100644 index 000000000..b4e793ffd --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getMedia.test.ts @@ -0,0 +1,136 @@ +import { getMedia } from './getMedia'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; + +const minimalMessage: IterableEmbeddedMessage = { + metadata: { messageId: 'msg-1', placementId: 1 }, +}; + +describe('getMedia', () => { + describe('viewType Notification', () => { + it('returns no media regardless of message content', () => { + const result = getMedia(IterableEmbeddedViewType.Notification, minimalMessage); + + expect(result).toEqual({ url: null, caption: null, shouldShow: false }); + }); + + it('returns no media even when message has mediaUrl and caption', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { + mediaUrl: 'https://example.com/image.png', + mediaUrlCaption: 'Example caption', + }, + }; + + const result = getMedia(IterableEmbeddedViewType.Notification, message); + + expect(result).toEqual({ url: null, caption: null, shouldShow: false }); + }); + }); + + describe('viewType Card', () => { + it('returns url and caption from message.elements, shouldShow true when url is non-empty', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { + mediaUrl: 'https://example.com/photo.jpg', + mediaUrlCaption: 'A nice photo', + }, + }; + + const result = getMedia(IterableEmbeddedViewType.Card, message); + + expect(result).toEqual({ + url: 'https://example.com/photo.jpg', + caption: 'A nice photo', + shouldShow: true, + }); + }); + + it('returns url only (caption null) when message has no mediaUrlCaption', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { mediaUrl: 'https://example.com/img.png' }, + }; + + const result = getMedia(IterableEmbeddedViewType.Card, message); + + expect(result).toEqual({ + url: 'https://example.com/img.png', + caption: null, + shouldShow: true, + }); + }); + + it('returns shouldShow false when mediaUrl is empty string', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { mediaUrl: '', mediaUrlCaption: 'Caption' }, + }; + + const result = getMedia(IterableEmbeddedViewType.Card, message); + + expect(result.url).toBe(''); + expect(result.caption).toBe('Caption'); + expect(result.shouldShow).toBe(false); + }); + + it('returns null url/caption and shouldShow false when message has no elements', () => { + const result = getMedia(IterableEmbeddedViewType.Card, minimalMessage); + + expect(result).toEqual({ url: null, caption: null, shouldShow: false }); + }); + + it('returns null url/caption when elements exist but mediaUrl is undefined', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { title: 'Title', body: 'Body' }, + }; + + const result = getMedia(IterableEmbeddedViewType.Card, message); + + expect(result).toEqual({ url: null, caption: null, shouldShow: false }); + }); + }); + + describe('viewType Banner', () => { + it('returns url and caption from message.elements, shouldShow true when url is non-empty', () => { + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { + mediaUrl: 'https://example.com/banner.png', + mediaUrlCaption: 'Banner caption', + }, + }; + + const result = getMedia(IterableEmbeddedViewType.Banner, message); + + expect(result).toEqual({ + url: 'https://example.com/banner.png', + caption: 'Banner caption', + shouldShow: true, + }); + }); + + it('returns null url/caption and shouldShow false when message has no elements', () => { + const result = getMedia(IterableEmbeddedViewType.Banner, minimalMessage); + + expect(result).toEqual({ url: null, caption: null, shouldShow: false }); + }); + }); + + describe('return shape', () => { + it('returns an object with url, caption, and shouldShow', () => { + const result = getMedia(IterableEmbeddedViewType.Card, minimalMessage); + + expect(Object.keys(result)).toHaveLength(3); + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('caption'); + expect(result).toHaveProperty('shouldShow'); + expect(typeof result.shouldShow).toBe('boolean'); + expect(result.url === null || typeof result.url === 'string').toBe(true); + expect(result.caption === null || typeof result.caption === 'string').toBe(true); + }); + }); +}); diff --git a/src/embedded/hooks/useEmbeddedView/getMedia.ts b/src/embedded/hooks/useEmbeddedView/getMedia.ts new file mode 100644 index 000000000..1e138d52d --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getMedia.ts @@ -0,0 +1,40 @@ +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import { IterableEmbeddedViewType } from '../../enums'; + +/** + * This function is used to get the media to render for a given embedded view + * type and message. + * + * @param viewType - The type of view to render. + * @param message - The message to render. + * @returns The media to render. + * + * @example + * ```ts + * const media = getMedia(IterableEmbeddedViewType.Notification, message); + * console.log(media.url); + * console.log(media.caption); + * console.log(media.shouldShow ? 'true' : 'false'); + * ``` + */ +export const getMedia = ( + /** The type of view to render. */ + viewType: IterableEmbeddedViewType, + /** The message to render. */ + message: IterableEmbeddedMessage +): { + /** The URL of the media to render. */ + url: string | null; + /** The caption of the media to render. */ + caption: string | null; + /** Whether the media should be shown. */ + shouldShow: boolean; +} => { + if (viewType === IterableEmbeddedViewType.Notification) { + return { url: null, caption: null, shouldShow: false }; + } + const url = message.elements?.mediaUrl ?? null; + const caption = message.elements?.mediaUrlCaption ?? null; + const shouldShow = !!url && url.length > 0; + return { url, caption, shouldShow }; +}; diff --git a/src/embedded/hooks/useEmbeddedView/getStyles.test.ts b/src/embedded/hooks/useEmbeddedView/getStyles.test.ts new file mode 100644 index 000000000..19c6092b6 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getStyles.test.ts @@ -0,0 +1,156 @@ +import { getStyles } from './getStyles'; +import { IterableEmbeddedViewType } from '../../enums'; +import { + embeddedBackgroundColors, + embeddedBorderColors, + embeddedBorderRadius, + embeddedBorderWidth, + embeddedPrimaryBtnBackgroundColors, + embeddedPrimaryBtnTextColors, + embeddedSecondaryBtnBackgroundColors, + embeddedSecondaryBtnTextColors, + embeddedTitleTextColors, + embeddedBodyTextColors, +} from '../../constants/embeddedViewDefaults'; + +describe('getStyles', () => { + describe('default styles by view type (no config)', () => { + it('returns Notification defaults when viewType is Notification', () => { + const result = getStyles(IterableEmbeddedViewType.Notification); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.notification); + expect(result.borderColor).toBe(embeddedBorderColors.notification); + expect(result.borderWidth).toBe(embeddedBorderWidth.notification); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.notification); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.notification); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.notification); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.notification); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.notification); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.notification); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.notification); + }); + + it('returns Card defaults when viewType is Card', () => { + const result = getStyles(IterableEmbeddedViewType.Card); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.card); + expect(result.borderColor).toBe(embeddedBorderColors.card); + expect(result.borderWidth).toBe(embeddedBorderWidth.card); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.card); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.card); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.card); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.card); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.card); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.card); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.card); + }); + + it('returns Banner defaults when viewType is Banner', () => { + const result = getStyles(IterableEmbeddedViewType.Banner); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.banner); + expect(result.borderColor).toBe(embeddedBorderColors.banner); + expect(result.borderWidth).toBe(embeddedBorderWidth.banner); + expect(result.borderCornerRadius).toBe(embeddedBorderRadius.banner); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.banner); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.banner); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.banner); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.banner); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.banner); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.banner); + }); + + it('returns Banner defaults for unknown viewType (default branch)', () => { + const unknownViewType = 999 as IterableEmbeddedViewType; + const result = getStyles(unknownViewType); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.banner); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.banner); + }); + }); + + describe('with null or undefined config', () => { + it('returns defaults when config is null', () => { + const result = getStyles(IterableEmbeddedViewType.Notification, null); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.notification); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.notification); + }); + + it('returns defaults when config is undefined', () => { + const result = getStyles(IterableEmbeddedViewType.Card, undefined); + + expect(result.backgroundColor).toBe(embeddedBackgroundColors.card); + }); + }); + + describe('config overrides defaults', () => { + it('uses config values when provided, overrides all style keys', () => { + const config = { + backgroundColor: '#000000', + borderColor: '#111111', + borderWidth: 2, + borderCornerRadius: 10, + primaryBtnBackgroundColor: '#222222', + primaryBtnTextColor: '#333333', + secondaryBtnBackgroundColor: '#444444', + secondaryBtnTextColor: '#555555', + titleTextColor: '#666666', + bodyTextColor: '#777777', + }; + + const result = getStyles(IterableEmbeddedViewType.Notification, config); + + expect(result.backgroundColor).toBe('#000000'); + expect(result.borderColor).toBe('#111111'); + expect(result.borderWidth).toBe(2); + expect(result.borderCornerRadius).toBe(10); + expect(result.primaryBtnBackgroundColor).toBe('#222222'); + expect(result.primaryBtnTextColor).toBe('#333333'); + expect(result.secondaryBtnBackgroundColor).toBe('#444444'); + expect(result.secondaryBtnTextColor).toBe('#555555'); + expect(result.titleTextColor).toBe('#666666'); + expect(result.bodyTextColor).toBe('#777777'); + }); + + it('overrides only provided config keys, rest use view-type defaults', () => { + const config = { + backgroundColor: '#abc', + borderCornerRadius: 12, + }; + + const result = getStyles(IterableEmbeddedViewType.Card, config); + + expect(result.backgroundColor).toBe('#abc'); + expect(result.borderCornerRadius).toBe(12); + expect(result.borderColor).toBe(embeddedBorderColors.card); + expect(result.borderWidth).toBe(embeddedBorderWidth.card); + expect(result.primaryBtnBackgroundColor).toBe(embeddedPrimaryBtnBackgroundColors.card); + expect(result.primaryBtnTextColor).toBe(embeddedPrimaryBtnTextColors.card); + expect(result.secondaryBtnBackgroundColor).toBe(embeddedSecondaryBtnBackgroundColors.card); + expect(result.secondaryBtnTextColor).toBe(embeddedSecondaryBtnTextColors.card); + expect(result.titleTextColor).toBe(embeddedTitleTextColors.card); + expect(result.bodyTextColor).toBe(embeddedBodyTextColors.card); + }); + }); + + describe('return shape', () => { + it('returns an object with all expected style keys', () => { + const result = getStyles(IterableEmbeddedViewType.Banner); + + expect(result).toMatchObject({ + backgroundColor: expect.any(String), + borderColor: expect.any(String), + borderWidth: expect.any(Number), + borderCornerRadius: expect.any(Number), + primaryBtnBackgroundColor: expect.any(String), + primaryBtnTextColor: expect.any(String), + secondaryBtnBackgroundColor: expect.any(String), + secondaryBtnTextColor: expect.any(String), + titleTextColor: expect.any(String), + bodyTextColor: expect.any(String), + }); + expect(Object.keys(result)).toHaveLength(10); + }); + }); +}); diff --git a/src/embedded/hooks/useEmbeddedView/getStyles.ts b/src/embedded/hooks/useEmbeddedView/getStyles.ts new file mode 100644 index 000000000..5781c1a84 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/getStyles.ts @@ -0,0 +1,83 @@ +import type { IterableEmbeddedViewConfig } from '../../types/IterableEmbeddedViewConfig'; +import { embeddedStyles } from '../../constants/embeddedViewDefaults'; +import { IterableEmbeddedViewType } from '../../enums'; + +/** + * Get the default style for the embedded view type. + * + * @param viewType - The type of view to render. + * @param colors - The colors to use for the default style. + * @returns The default style. + */ +const getDefaultStyle = ( + viewType: IterableEmbeddedViewType, + colors: { + banner: T; + card: T; + notification: T; + } +): T => { + switch (viewType) { + case IterableEmbeddedViewType.Notification: + return colors.notification; + case IterableEmbeddedViewType.Card: + return colors.card; + default: + return colors.banner; + } +}; + +/** + * Get the style for the embedded view type. + * + * If a style is provided in the config, it will take precedence over the default style. + * + * @param viewType - The type of view to render. + * @param c - The config to use for the styles. + * @returns The styles. + * + * @example + * ```ts + * const styles = getStyles(IterableEmbeddedViewType.Notification, { + * backgroundColor: '#000000', + * borderColor: '#000000', + * borderWidth: 1, + * borderCornerRadius: 10, + * primaryBtnBackgroundColor: '#000000', + * primaryBtnTextColor: '#000000', + * }); + * ``` + */ +export const getStyles = ( + viewType: IterableEmbeddedViewType, + c?: IterableEmbeddedViewConfig | null +) => { + return { + backgroundColor: + c?.backgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.backgroundColor), + borderColor: + c?.borderColor ?? getDefaultStyle(viewType, embeddedStyles.borderColor), + borderWidth: + c?.borderWidth ?? getDefaultStyle(viewType, embeddedStyles.borderWidth), + borderCornerRadius: + c?.borderCornerRadius ?? + getDefaultStyle(viewType, embeddedStyles.borderCornerRadius), + primaryBtnBackgroundColor: + c?.primaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnBackgroundColor), + primaryBtnTextColor: + c?.primaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnTextColor), + secondaryBtnBackgroundColor: + c?.secondaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnBackground), + secondaryBtnTextColor: + c?.secondaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnTextColor), + titleTextColor: + c?.titleTextColor ?? getDefaultStyle(viewType, embeddedStyles.titleText), + bodyTextColor: + c?.bodyTextColor ?? getDefaultStyle(viewType, embeddedStyles.bodyText), + }; +}; diff --git a/src/embedded/hooks/useEmbeddedView/index.ts b/src/embedded/hooks/useEmbeddedView/index.ts new file mode 100644 index 000000000..bf1a77d44 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/index.ts @@ -0,0 +1,2 @@ +export * from './useEmbeddedView'; +export { useEmbeddedView as default } from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts new file mode 100644 index 000000000..2e065b951 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-native'; + +import { Iterable } from '../../../core/classes/Iterable'; +import { IterableAction } from '../../../core/classes/IterableAction'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { useEmbeddedView } from './useEmbeddedView'; +import { getMedia } from './getMedia'; +import { getStyles } from './getStyles'; + +jest.mock('./getMedia'); +jest.mock('./getStyles'); + +const mockGetMedia = getMedia as jest.MockedFunction; +const mockGetStyles = getStyles as jest.MockedFunction; + +const minimalMessage: IterableEmbeddedMessage = { + metadata: { messageId: 'msg-1', placementId: 1 }, +}; + +const defaultMedia = { url: null, caption: null, shouldShow: false }; +const defaultStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderWidth: 1, + borderCornerRadius: 10, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#ffffff', + titleTextColor: '#000000', + bodyTextColor: '#000000', +}; + +describe('useEmbeddedView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetMedia.mockReturnValue(defaultMedia); + mockGetStyles.mockReturnValue(defaultStyles); + jest.spyOn(Iterable.embeddedManager, 'handleClick').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('return shape', () => { + it('returns handleButtonClick, handleMessageClick, media, and parsedStyles', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(result.current).toHaveProperty('handleButtonClick'); + expect(result.current).toHaveProperty('handleMessageClick'); + expect(result.current).toHaveProperty('media'); + expect(result.current).toHaveProperty('parsedStyles'); + expect(typeof result.current.handleButtonClick).toBe('function'); + expect(typeof result.current.handleMessageClick).toBe('function'); + }); + }); + + describe('getMedia / getStyles delegation', () => { + it('calls getMedia with viewType and message', () => { + renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Card, { message: minimalMessage }) + ); + + expect(mockGetMedia).toHaveBeenCalledWith(IterableEmbeddedViewType.Card, minimalMessage); + }); + + it('calls getStyles with viewType and config', () => { + const config = { backgroundColor: '#000000' }; + renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + config, + }) + ); + + expect(mockGetStyles).toHaveBeenCalledWith(IterableEmbeddedViewType.Notification, config); + }); + + it('returns media from getMedia and parsedStyles from getStyles', () => { + const customMedia = { url: 'https://example.com/img.png', caption: 'Cap', shouldShow: true }; + const customStyles = { ...defaultStyles, backgroundColor: '#111111' }; + mockGetMedia.mockReturnValue(customMedia); + mockGetStyles.mockReturnValue(customStyles); + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Banner, { message: minimalMessage }) + ); + + expect(result.current.media).toEqual(customMedia); + expect(result.current.parsedStyles).toEqual(customStyles); + }); + }); + + describe('handleButtonClick', () => { + it('calls onButtonClick with the button and message', () => { + const onButtonClick = jest.fn(); + const button: IterableEmbeddedMessageElementsButton = { + id: 'btn-1', + title: 'Click me', + action: null, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + onButtonClick, + }) + ); + + act(() => { + result.current.handleButtonClick(button); + }); + + expect(onButtonClick).toHaveBeenCalledTimes(1); + expect(onButtonClick).toHaveBeenCalledWith(button, minimalMessage); + }); + + it('calls Iterable.embeddedManager.handleClick with message, button.id, and button.action', () => { + const action = new IterableAction('openUrl', 'https://example.com'); + const button: IterableEmbeddedMessageElementsButton = { + id: 'btn-2', + title: 'Link', + action, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + }) + ); + + act(() => { + result.current.handleButtonClick(button); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + minimalMessage, + 'btn-2', + action + ); + }); + }); + + describe('handleMessageClick', () => { + it('calls onMessageClick', () => { + const onMessageClick = jest.fn(); + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + onMessageClick, + }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(onMessageClick).toHaveBeenCalledTimes(1); + }); + + it('calls Iterable.embeddedManager.handleClick with message, null, and message.elements?.defaultAction', () => { + const defaultAction = new IterableAction('openUrl', 'https://iterable.com'); + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { defaultAction }, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + message, + null, + defaultAction + ); + }); + + it('calls embeddedManager.handleClick with undefined defaultAction when message has no elements', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + minimalMessage, + null, + undefined + ); + }); + }); + + describe('default callbacks', () => { + it('does not throw when handleButtonClick is invoked without provided onButtonClick', () => { + const button: IterableEmbeddedMessageElementsButton = { id: 'b', title: null, action: null }; + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(() => { + act(() => { + result.current.handleButtonClick(button); + }); + }).not.toThrow(); + }); + + it('does not throw when handleMessageClick is invoked without provided onMessageClick', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(() => { + act(() => { + result.current.handleMessageClick(); + }); + }).not.toThrow(); + }); + }); + + // memoization behavior (useMemo) is indirectly exercised above via getMedia/getStyles calls +}); diff --git a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts new file mode 100644 index 000000000..7c9559e77 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts @@ -0,0 +1,83 @@ +import { useCallback, useMemo } from 'react'; +import { Iterable } from '../../../core/classes/Iterable'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { normalizeEmbeddedViewConfig } from '../../utils/normalizeEmbeddedViewConfig'; +import { getMedia } from './getMedia'; +import { getStyles } from './getStyles'; + +const noop = (..._args: unknown[]) => {}; + +/** + * This hook is used to manage the lifecycle of an embedded view. + * + * @param viewType - The type of view to render. + * @param props - The props for the embedded view. + * @returns The embedded view. + * + * @example + * ```tsx + * const { handleButtonClick, handleMessageClick, media, parsedStyles } = useEmbeddedView(IterableEmbeddedViewType.Notification, { + * message, + * config, + * onButtonClick, + * onMessageClick, + * }); + * + * return ( + * + * {media.url} + * {media.caption} + * {parsedStyles.backgroundColor} + * + * ); + * ``` + */ +export const useEmbeddedView = ( + /** The type of view to render. */ + viewType: IterableEmbeddedViewType, + /** The props for the embedded view. */ + { + message, + config, + onButtonClick = noop, + onMessageClick = noop, + }: IterableEmbeddedComponentProps +) => { + const normalizedConfig = useMemo( + () => normalizeEmbeddedViewConfig(config), + [config] + ); + + const parsedStyles = useMemo(() => { + return getStyles(viewType, normalizedConfig); + }, [viewType, normalizedConfig]); + const media = useMemo(() => { + return getMedia(viewType, message); + }, [viewType, message]); + + const handleButtonClick = useCallback( + (button: IterableEmbeddedMessageElementsButton) => { + onButtonClick(button, message); + Iterable.embeddedManager.handleClick(message, button.id, button.action); + }, + [onButtonClick, message] + ); + + const handleMessageClick = useCallback(() => { + onMessageClick(message); + Iterable.embeddedManager.handleClick( + message, + null, + message.elements?.defaultAction + ); + }, [message, onMessageClick]); + + return { + handleButtonClick, + handleMessageClick, + media, + parsedStyles, + }; +}; diff --git a/src/embedded/index.ts b/src/embedded/index.ts index 15eb796c9..107bb59fe 100644 --- a/src/embedded/index.ts +++ b/src/embedded/index.ts @@ -1,2 +1,6 @@ export * from './classes'; +export * from './components'; +export * from './enums'; +export * from './hooks'; export * from './types'; + diff --git a/src/embedded/types/IterableEmbeddedComponentProps.ts b/src/embedded/types/IterableEmbeddedComponentProps.ts new file mode 100644 index 000000000..f80bdd91b --- /dev/null +++ b/src/embedded/types/IterableEmbeddedComponentProps.ts @@ -0,0 +1,17 @@ +import type { IterableEmbeddedMessage } from './IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedViewConfig } from './IterableEmbeddedViewConfig'; + +export interface IterableEmbeddedComponentProps { + /** The message to render. */ + message: IterableEmbeddedMessage; + /** The config for the embedded view. */ + config?: IterableEmbeddedViewConfig | null; + /** The function to call when a button is clicked. */ + onButtonClick?: ( + button: IterableEmbeddedMessageElementsButton, + message: IterableEmbeddedMessage + ) => void; + /** The function to call when the message is clicked. */ + onMessageClick?: (message: IterableEmbeddedMessage) => void; +} diff --git a/src/embedded/types/IterableEmbeddedViewConfig.ts b/src/embedded/types/IterableEmbeddedViewConfig.ts new file mode 100644 index 000000000..6a41edd8a --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewConfig.ts @@ -0,0 +1,27 @@ +import type { ColorValue } from 'react-native'; + +/** + * Represents view-level styling configuration for an embedded view. + */ +export interface IterableEmbeddedViewConfig { + /** Background color hex (e.g., 0xFF0000) */ + backgroundColor?: ColorValue; + /** Border color hex */ + borderColor?: ColorValue; + /** Border width in pixels */ + borderWidth?: number; + /** Corner radius in points */ + borderCornerRadius?: number; + /** Primary button background color hex */ + primaryBtnBackgroundColor?: ColorValue; + /** Primary button text color hex */ + primaryBtnTextColor?: ColorValue; + /** Secondary button background color hex */ + secondaryBtnBackgroundColor?: ColorValue; + /** Secondary button text color hex */ + secondaryBtnTextColor?: ColorValue; + /** Title text color hex */ + titleTextColor?: ColorValue; + /** Body text color hex */ + bodyTextColor?: ColorValue; +} diff --git a/src/embedded/types/index.ts b/src/embedded/types/index.ts index 29b809ebf..66deee21e 100644 --- a/src/embedded/types/index.ts +++ b/src/embedded/types/index.ts @@ -1,5 +1,7 @@ +export * from './IterableEmbeddedComponentProps'; export * from './IterableEmbeddedMessage'; export * from './IterableEmbeddedMessageElements'; export * from './IterableEmbeddedMessageElementsButton'; export * from './IterableEmbeddedMessageElementsText'; export * from './IterableEmbeddedMessageMetadata'; +export * from './IterableEmbeddedViewConfig'; diff --git a/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts new file mode 100644 index 000000000..96cfe6402 --- /dev/null +++ b/src/embedded/utils/normalizeEmbeddedViewConfig.test.ts @@ -0,0 +1,106 @@ +import { normalizeEmbeddedViewConfig } from './normalizeEmbeddedViewConfig'; + +describe('normalizeEmbeddedViewConfig', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns null or undefined unchanged', () => { + expect(normalizeEmbeddedViewConfig(null)).toBeNull(); + expect(normalizeEmbeddedViewConfig(undefined)).toBeUndefined(); + }); + + it('parses numeric strings for borderWidth and borderCornerRadius', () => { + const input = { + borderWidth: '45', + borderCornerRadius: '12.5', + backgroundColor: '#fff', + }; + + // Runtime JSON / native payloads may use strings for numeric fields. + const result = normalizeEmbeddedViewConfig(input as never); + + expect(result).toEqual({ + borderWidth: 45, + borderCornerRadius: 12.5, + backgroundColor: '#fff', + }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('trims whitespace before parsing numeric strings', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: ' 8 ', + } as never); + + expect(result?.borderWidth).toBe(8); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('leaves valid numbers unchanged', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: 3, + borderCornerRadius: 0, + }); + + expect(result?.borderWidth).toBe(3); + expect(result?.borderCornerRadius).toBe(0); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('drops non-parsable strings and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: 'nope', + borderCornerRadius: '10', + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBe(10); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('borderWidth'); + }); + + it('drops empty strings and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: ' ', + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('drops NaN and Infinity numbers and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: Number.NaN, + borderCornerRadius: Number.POSITIVE_INFINITY, + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(result?.borderCornerRadius).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(2); + }); + + it('drops invalid types and warns', () => { + const result = normalizeEmbeddedViewConfig({ + borderWidth: true, + } as never); + + expect(result?.borderWidth).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('does not mutate the original config object', () => { + const original = { borderWidth: '7' as const }; + const snapshot = { ...original }; + + normalizeEmbeddedViewConfig(original as never); + + expect(original).toEqual(snapshot); + }); +}); diff --git a/src/embedded/utils/normalizeEmbeddedViewConfig.ts b/src/embedded/utils/normalizeEmbeddedViewConfig.ts new file mode 100644 index 000000000..63aa52eec --- /dev/null +++ b/src/embedded/utils/normalizeEmbeddedViewConfig.ts @@ -0,0 +1,75 @@ +import type { IterableEmbeddedViewConfig } from '../types/IterableEmbeddedViewConfig'; + +const NUMERIC_KEYS: (keyof Pick< + IterableEmbeddedViewConfig, + 'borderWidth' | 'borderCornerRadius' +>)[] = ['borderWidth', 'borderCornerRadius']; + +function coerceNumericField( + key: 'borderWidth' | 'borderCornerRadius', + value: unknown +): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value === 'number') { + if (Number.isFinite(value)) { + return value; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: expected a finite number, got ${String(value)}` + ); + return undefined; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: empty string is not a valid number` + ); + return undefined; + } + const n = parseFloat(trimmed); + if (Number.isFinite(n)) { + return n; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: could not parse string as a number: ${JSON.stringify(value)}` + ); + return undefined; + } + console.warn( + `[IterableEmbeddedView] Ignoring ${String(key)}: expected number or numeric string, got ${typeof value}` + ); + return undefined; +} + +/** + * Returns a shallow copy of config with numeric fields coerced from strings when possible. + * Values that cannot be coerced are omitted so style resolution can fall back to defaults. + */ +export function normalizeEmbeddedViewConfig( + config: IterableEmbeddedViewConfig | null | undefined +): IterableEmbeddedViewConfig | null | undefined { + if (config == null) { + return config; + } + const next: IterableEmbeddedViewConfig = { ...config }; + const loose = config as Record; + for (const key of NUMERIC_KEYS) { + const raw = loose[key as string]; + if (raw === undefined) { + continue; + } + if (typeof raw === 'number' && Number.isFinite(raw)) { + continue; + } + const coerced = coerceNumericField(key, raw); + if (coerced === undefined) { + delete next[key]; + } else { + next[key] = coerced; + } + } + return next; +} diff --git a/src/index.tsx b/src/index.tsx index 75c8489ec..13cb6915d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,18 @@ export type { IterableEdgeInsetDetails, IterableRetryPolicy, } from './core/types'; +export { + IterableEmbeddedManager, + IterableEmbeddedView, + IterableEmbeddedViewType, + type IterableEmbeddedComponentProps, + type IterableEmbeddedMessage, + type IterableEmbeddedMessageElements, + type IterableEmbeddedMessageElementsButton, + type IterableEmbeddedMessageElementsText, + type IterableEmbeddedViewConfig, + type IterableEmbeddedViewProps, +} from './embedded'; export { IterableHtmlInAppContent, IterableInAppCloseSource, @@ -59,7 +71,3 @@ export { type IterableInboxProps, type IterableInboxRowViewModel, } from './inbox'; -export { - IterableEmbeddedManager, - type IterableEmbeddedMessage, -} from './embedded'; diff --git a/src/itblBuildInfo.ts b/src/itblBuildInfo.ts index 4a5781e6f..4cdf2feeb 100644 --- a/src/itblBuildInfo.ts +++ b/src/itblBuildInfo.ts @@ -3,5 +3,5 @@ * It contains the version of the package */ export const buildInfo = { - version: '2.2.2', + version: '3.0.0', }; diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 000000000..f729a859f --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,7 @@ +declare module '*.png' { + import type { ImageSourcePropType } from 'react-native'; + + const value: ImageSourcePropType; + export default value; +} +