@sellwild/react-native-sdk 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Sellwild mobile advertising SDK for React Native. Server-side header bidding thr
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install @sellwild/react-native-sdk react-native-webview
8
+ npm install @sellwild/react-native-sdk
9
9
  cd ios && pod install && cd ..
10
10
  ```
11
11
 
@@ -67,7 +67,7 @@ export default function App() {
67
67
  ## Components
68
68
 
69
69
  - **`SellwildBanner`** — Display ad (300x250, 320x50, 728x90)
70
- - **`SellwildWidget`** — Full marketplace widget with listings and ads
70
+ - **`SellwildFeed`** — All-in-one native feed: listings interleaved with native ads
71
71
  - **`SellwildListingCard`** — Individual listing card component
72
72
  - **`useSellwildListings`** — Hook for fetching listings data
73
73
 
@@ -28,6 +28,8 @@ Pod::Spec.new do |s|
28
28
  # The host app must already pull in SellwildSDK (the iOS SDK pod). We
29
29
  # depend on it explicitly so `pod install` resolves both with one
30
30
  # declaration in the host Podfile.
31
- s.dependency 'SellwildSDK', '>= 1.4.0'
31
+ # Floor tracks this package's version (lockstep releases): the bridge uses
32
+ # SellwildGeo / SellwildEid / prewarm, which older SellwildSDK pods lack.
33
+ s.dependency 'SellwildSDK', ">= #{package['version']}"
32
34
  s.dependency 'React-Core'
33
35
  end
@@ -76,7 +76,9 @@ dependencies {
76
76
  // The native SDK that owns SellwildAdView, SellwildPrebidMobile, etc.
77
77
  // Resolves to the published AAR off https://maven.sellwild.com/releases
78
78
  // for npm consumers, or to mavenLocal in the SDK monorepo dev workflow.
79
- implementation "com.sellwild:sdk:1.4.1"
79
+ // Keep in lockstep with the SDK release version: the bridge uses APIs
80
+ // (SellwildGeo, SellwildEid, SellwildSDK.prewarm) that older AARs lack.
81
+ implementation "com.sellwild:sdk:1.8.0"
80
82
  }
81
83
 
82
84
  // Force kotlin-stdlib down to the bridge's declared Kotlin version. The
@@ -1,5 +1,7 @@
1
1
  package com.sellwild.rnsdk
2
2
 
3
+ import android.content.Context
4
+ import android.view.View
3
5
  import com.facebook.react.bridge.ReactContext
4
6
  import com.facebook.react.bridge.ReadableMap
5
7
  import com.facebook.react.common.MapBuilder
@@ -15,6 +17,32 @@ import com.sellwild.sdk.SellwildGrowthCodeConfig
15
17
  import com.sellwild.sdk.SellwildLocalizedListingsConfig
16
18
  import org.json.JSONObject
17
19
 
20
+ /**
21
+ * React Native (Paper) only lays out views in its own shadow tree; the child
22
+ * views a native component adds itself — here the GAM/Prebid banner and the
23
+ * WebView the viewability tracker watches — are never measured or laid out, so
24
+ * they render 0-size and fail the impression viewability check
25
+ * (getWidth()>0 / getGlobalVisibleRect), which means NO viewable impression and
26
+ * NO burl fires. Re-run measure + layout on our RN-assigned bounds whenever a
27
+ * child requests layout (e.g. when the ad renders asynchronously). This is the
28
+ * standard RN native-view fix (react-native-webview/maps/video do the same).
29
+ * Not needed on iOS RN, which lays subviews out via Auto Layout constraints.
30
+ */
31
+ internal class RnSellwildAdView(context: Context) : SellwildAdView(context) {
32
+ private val measureAndLayout = Runnable {
33
+ measure(
34
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
35
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
36
+ )
37
+ layout(left, top, right, bottom)
38
+ }
39
+
40
+ override fun requestLayout() {
41
+ super.requestLayout()
42
+ post(measureAndLayout)
43
+ }
44
+ }
45
+
18
46
  /**
19
47
  * Bridges the JS <SellwildBanner> component to the native
20
48
  * com.sellwild.sdk.SellwildAdView.
@@ -62,10 +90,13 @@ class SellwildBannerViewManager : SimpleViewManager<SellwildAdView>() {
62
90
  // only call setup() + load() once per identity tuple. Refresh of
63
91
  // the rendered ad is driven by the SDK's internal timer.
64
92
  var lastAppliedKey: String? = null,
93
+ // Last applied config, compared by value. A hashCode() in the key is
94
+ // only a probabilistic match (collisions ⇒ a real change is skipped).
95
+ var lastAppliedConfig: Map<String, Any?>? = null,
65
96
  )
66
97
 
67
98
  override fun createViewInstance(reactContext: ThemedReactContext): SellwildAdView {
68
- val view = SellwildAdView(reactContext)
99
+ val view = RnSellwildAdView(reactContext)
69
100
  pending[view] = PendingProps()
70
101
  view.listener = object : SellwildAdView.Listener {
71
102
  override fun onAdLoaded(adView: SellwildAdView) {
@@ -90,6 +121,13 @@ class SellwildBannerViewManager : SimpleViewManager<SellwildAdView>() {
90
121
  emit(reactContext, adView, "onAdClicked", null)
91
122
  }
92
123
 
124
+ override fun onAdBlocked(adView: SellwildAdView, zoneId: String) {
125
+ val payload = com.facebook.react.bridge.Arguments.createMap().apply {
126
+ putString("zoneId", zoneId)
127
+ }
128
+ emit(reactContext, adView, "onAdBlocked", payload)
129
+ }
130
+
93
131
  override fun onAdFailed(adView: SellwildAdView, message: String) {
94
132
  val payload = com.facebook.react.bridge.Arguments.createMap().apply {
95
133
  putString("message", message)
@@ -139,9 +177,11 @@ class SellwildBannerViewManager : SimpleViewManager<SellwildAdView>() {
139
177
  val adSize = adSizeFromLabel(sizeLabel) ?: return
140
178
 
141
179
  // Skip if the props identity hasn't changed since last apply.
142
- val key = "$sizeLabel|$zoneId|${p.adStack}|${configMap.hashCode()}"
143
- if (p.lastAppliedKey == key) return
180
+ val key = "$sizeLabel|$zoneId|${p.adStack}"
181
+ val configValue = configMap.toHashMap()
182
+ if (p.lastAppliedKey == key && p.lastAppliedConfig == configValue) return
144
183
  p.lastAppliedKey = key
184
+ p.lastAppliedConfig = configValue
145
185
 
146
186
  val config = configFromMap(configMap)
147
187
  // JS resolves the stack from config and passes it as the override so
@@ -171,6 +211,7 @@ class SellwildBannerViewManager : SimpleViewManager<SellwildAdView>() {
171
211
  .put("onAdClicked", MapBuilder.of("registrationName", "onAdClicked"))
172
212
  .put("onAdFailed", MapBuilder.of("registrationName", "onAdFailed"))
173
213
  .put("onAdResize", MapBuilder.of("registrationName", "onAdResize"))
214
+ .put("onAdBlocked", MapBuilder.of("registrationName", "onAdBlocked"))
174
215
  .build()
175
216
  }
176
217
 
@@ -1,8 +1,11 @@
1
1
  package com.sellwild.rnsdk
2
2
 
3
+ import android.content.Context
4
+ import android.view.View
3
5
  import com.facebook.react.bridge.Arguments
4
6
  import com.facebook.react.bridge.ReactContext
5
7
  import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.ReadableType
6
9
  import com.facebook.react.bridge.WritableMap
7
10
  import com.facebook.react.common.MapBuilder
8
11
  import com.facebook.react.uimanager.SimpleViewManager
@@ -17,6 +20,29 @@ import com.sellwild.sdk.SellwildLocalizedListingsConfig
17
20
  import com.sellwild.sdk.SellwildSDK
18
21
  import org.json.JSONObject
19
22
 
23
+ /**
24
+ * React Native (Paper) only lays out its own shadow-tree views; children a
25
+ * native component adds itself (the feed's RecyclerView + ad WebViews) are never
26
+ * measured/laid out, so they render 0-size and fail the impression viewability
27
+ * check — no viewable impression, no burl. Re-run measure + layout on our
28
+ * RN-assigned bounds whenever a child requests layout. Standard RN native-view
29
+ * fix; not needed on iOS RN (Auto Layout constraints handle it).
30
+ */
31
+ internal class RnSellwildFeedView(context: Context) : SellwildFeedView(context) {
32
+ private val measureAndLayout = Runnable {
33
+ measure(
34
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
35
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
36
+ )
37
+ layout(left, top, right, bottom)
38
+ }
39
+
40
+ override fun requestLayout() {
41
+ super.requestLayout()
42
+ post(measureAndLayout)
43
+ }
44
+ }
45
+
20
46
  /**
21
47
  * Bridges the JS <SellwildFeed> component to the native
22
48
  * com.sellwild.sdk.SellwildFeedView (all-in-one native feed: COL1-scheduled
@@ -27,6 +53,9 @@ import org.json.JSONObject
27
53
  * The bridge re-runs the CDN decoder against `config.remote` so
28
54
  * feed-specific fields (COL1, bgColor, mobileZids, listingsUrl, …)
29
55
  * are populated identically to a native [SellwildSDK.configure] call.
56
+ * - scrollEnabled: bool — disable internal scrolling for embedding.
57
+ * - consumeListingTaps: bool — when true the host owns listing taps; the
58
+ * SDK does not open Custom Tabs.
30
59
  *
31
60
  * Events emitted to JS:
32
61
  * - onFeedLoaded
@@ -48,11 +77,16 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
48
77
 
49
78
  private data class PendingProps(
50
79
  var config: ReadableMap? = null,
51
- var lastAppliedKey: String? = null,
80
+ // When true, listing taps are only forwarded to JS and the SDK does
81
+ // not open Custom Tabs. Read at tap time, so it applies live.
82
+ var consumeListingTaps: Boolean = false,
83
+ // Last applied config, compared by value. A hashCode() key is only a
84
+ // probabilistic match (collisions ⇒ a real config change is skipped).
85
+ var lastAppliedConfig: Map<String, Any?>? = null,
52
86
  )
53
87
 
54
88
  override fun createViewInstance(reactContext: ThemedReactContext): SellwildFeedView {
55
- val view = SellwildFeedView(reactContext)
89
+ val view = RnSellwildFeedView(reactContext)
56
90
  pending[view] = PendingProps()
57
91
  view.listener = object : SellwildFeedView.Listener {
58
92
  override fun onListingTap(listing: SellwildListing): Boolean {
@@ -60,8 +94,10 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
60
94
  putMap("listing", listingPayload(listing))
61
95
  }
62
96
  emit(reactContext, view, "onListingTap", payload)
63
- // SDK still owns navigation (Custom Tabs); JS just observes.
64
- return false
97
+ // JS can't return a value through an (async) RN event, so the
98
+ // `consumeListingTaps` prop decides whether the SDK opens
99
+ // Custom Tabs (false, default) or leaves navigation to the host.
100
+ return pending[view]?.consumeListingTaps ?: false
65
101
  }
66
102
 
67
103
  override fun onAdImpression(zoneId: String) {
@@ -111,15 +147,20 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
111
147
  view.scrollEnabled = value
112
148
  }
113
149
 
150
+ @ReactProp(name = "consumeListingTaps", defaultBoolean = false)
151
+ fun setConsumeListingTaps(view: SellwildFeedView, value: Boolean) {
152
+ pendingFor(view).consumeListingTaps = value
153
+ }
154
+
114
155
  override fun onAfterUpdateTransaction(view: SellwildFeedView) {
115
156
  super.onAfterUpdateTransaction(view)
116
157
 
117
158
  val p = pending[view] ?: return
118
159
  val configMap = p.config ?: return
119
160
 
120
- val key = "${configMap.hashCode()}"
121
- if (p.lastAppliedKey == key) return
122
- p.lastAppliedKey = key
161
+ val configValue = configMap.toHashMap()
162
+ if (p.lastAppliedConfig == configValue) return
163
+ p.lastAppliedConfig = configValue
123
164
 
124
165
  val config = configFromMap(configMap)
125
166
  view.setup(config)
@@ -128,6 +169,9 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
128
169
 
129
170
  override fun onDropViewInstance(view: SellwildFeedView) {
130
171
  pending.remove(view)
172
+ // RN unmounted the feed for good: stop its ad rows' refresh and release
173
+ // the Activity (detach alone doesn't when MOBILE_PAUSE_REFRESH_DETACHED=false).
174
+ view.destroy()
131
175
  super.onDropViewInstance(view)
132
176
  }
133
177
 
@@ -160,6 +204,23 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
160
204
  companion object {
161
205
  const val REACT_CLASS = "SellwildFeedView"
162
206
 
207
+ /**
208
+ * A zone id from JS: a string, or a number (core config's default is
209
+ * `0`, meaning unset). `getString` throws on a number, so read either
210
+ * type. Returns null for missing, empty, or `0`.
211
+ */
212
+ internal fun zoneIdOrNull(map: ReadableMap, key: String): String? {
213
+ if (!map.hasKey(key) || map.isNull(key)) return null
214
+ val s = when (map.getType(key)) {
215
+ ReadableType.String -> map.getString(key)
216
+ ReadableType.Number -> map.getDouble(key).let { d ->
217
+ if (d % 1.0 == 0.0) d.toLong().toString() else d.toString()
218
+ }
219
+ else -> null
220
+ }?.trim()
221
+ return s?.takeIf { it.isNotEmpty() && it != "0" }
222
+ }
223
+
163
224
  /**
164
225
  * Build a full SellwildConfig from a JS map. The JS side passes the
165
226
  * resolved CDN payload under `remote`; we re-run the canonical CDN
@@ -199,13 +260,13 @@ class SellwildFeedViewManager : SimpleViewManager<SellwildFeedView>() {
199
260
  if (map.hasKey("slug")) config = config.copy(slug = map.getString("slug") ?: config.slug)
200
261
  if (map.hasKey("listingsUrl")) config = config.copy(listingsUrl = map.getString("listingsUrl"))
201
262
  if (map.hasKey("priceColor")) config = config.copy(priceColor = map.getString("priceColor") ?: config.priceColor)
202
- if (map.hasKey("bannerZid")) config = config.copy(bannerZid = map.getString("bannerZid"))
203
- if (map.hasKey("bottomBannerZid")) config = config.copy(bottomBannerZid = map.getString("bottomBannerZid"))
263
+ zoneIdOrNull(map, "bannerZid")?.let { config = config.copy(bannerZid = it) }
264
+ zoneIdOrNull(map, "bottomBannerZid")?.let { config = config.copy(bottomBannerZid = it) }
204
265
  // mobileZids / mobileBannerZid are OS-suffix-resolved by SellwildSDK.apply
205
266
  // when `remote` is present; only fall back to the flat (OS-agnostic) JS
206
267
  // values when there was no remote payload to resolve from (parity w/ iOS).
207
268
  if (!map.hasKey("remote") || map.isNull("remote")) {
208
- if (map.hasKey("mobileBannerZid")) config = config.copy(mobileBannerZid = map.getString("mobileBannerZid"))
269
+ zoneIdOrNull(map, "mobileBannerZid")?.let { config = config.copy(mobileBannerZid = it) }
209
270
  if (map.hasKey("mobileZids") && !map.isNull("mobileZids")) {
210
271
  val arr = map.getArray("mobileZids")
211
272
  if (arr != null) config = config.copy(mobileZids = (0 until arr.size()).mapNotNull { arr.getString(it) })
@@ -5,9 +5,11 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule
5
5
  import com.facebook.react.bridge.ReactMethod
6
6
  import com.facebook.react.bridge.ReadableArray
7
7
  import com.facebook.react.bridge.ReadableMap
8
+ import com.facebook.react.bridge.UiThreadUtil
8
9
  import com.sellwild.sdk.SellwildEid
9
10
  import com.sellwild.sdk.SellwildEidUid
10
11
  import com.sellwild.sdk.SellwildPrebidMobile
12
+ import com.sellwild.sdk.SellwildSDK
11
13
 
12
14
  /**
13
15
  * React Native method module for the native Sellwild SDK's runtime setters.
@@ -42,6 +44,22 @@ class SellwildModule(reactContext: ReactApplicationContext) :
42
44
  SellwildPrebidMobile.setExternalUserIds(toEids(eids))
43
45
  }
44
46
 
47
+ /**
48
+ * JS: `SellwildRNModule.prewarm(nativeConfig)`. Pre-initializes the native ad
49
+ * stack (Prebid + ad server SDK) before the first ad view mounts, so the
50
+ * first impression doesn't incur cold-start init latency. Mirrors the native
51
+ * Android-only [SellwildSDK.prewarm]; idempotent. Runs on the main thread —
52
+ * the ad-SDK inits expect it. Reuses the banner manager's config mapping so
53
+ * the payload shape matches `<SellwildBanner config=...>`.
54
+ */
55
+ @ReactMethod
56
+ fun prewarm(config: ReadableMap?) {
57
+ if (config == null) return
58
+ val ctx = reactApplicationContext
59
+ val cfg = SellwildBannerViewManager.configFromMap(config)
60
+ UiThreadUtil.runOnUiThread { SellwildSDK.prewarm(ctx, cfg) }
61
+ }
62
+
45
63
  private fun toEids(arr: ReadableArray?): List<SellwildEid> {
46
64
  if (arr == null) return emptyList()
47
65
  val out = ArrayList<SellwildEid>()
@@ -23,5 +23,6 @@ RCT_EXPORT_VIEW_PROPERTY(onHouseAdImpression, RCTDirectEventBlock)
23
23
  RCT_EXPORT_VIEW_PROPERTY(onAdClicked, RCTDirectEventBlock)
24
24
  RCT_EXPORT_VIEW_PROPERTY(onAdFailed, RCTDirectEventBlock)
25
25
  RCT_EXPORT_VIEW_PROPERTY(onAdResize, RCTDirectEventBlock)
26
+ RCT_EXPORT_VIEW_PROPERTY(onAdBlocked, RCTDirectEventBlock)
26
27
 
27
28
  @end
@@ -53,11 +53,18 @@ final class SellwildBannerHostView: UIView, SellwildAdViewDelegate {
53
53
  @objc var onAdFailed: RCTDirectEventBlock?
54
54
  /// Emitted with the rendered creative size so JS can resize the slot.
55
55
  @objc var onAdResize: RCTDirectEventBlock?
56
+ /// Boltive blocked the rendered creative (MOBILE_BOLTIVE_ENABLED only).
57
+ @objc var onAdBlocked: RCTDirectEventBlock?
56
58
 
57
59
  // MARK: Internals
58
60
 
59
61
  private var adView: SellwildAdView?
60
62
  private var lastAppliedKey: String?
63
+ /// Last applied `config` map, compared by value (`isEqual:` ⇒
64
+ /// `isEqualToDictionary:`, deep). NOT `NSDictionary.hash` — that is
65
+ /// just the entry count, so a partnerCode / slug / geo / remote change
66
+ /// with the same key count would be silently ignored.
67
+ private var lastAppliedConfig: NSDictionary?
61
68
  private var needsApply: Bool = false
62
69
 
63
70
  override init(frame: CGRect) {
@@ -87,9 +94,10 @@ final class SellwildBannerHostView: UIView, SellwildAdViewDelegate {
87
94
  // refresh of the rendered ad is driven by the SDK's internal
88
95
  // timer, not by JS re-renders.
89
96
  let stackStr = (adStack as String?) ?? ""
90
- let key = "\(sizeStr)|\(zid)|\(stackStr)|\(cfgMap.hash)"
91
- if lastAppliedKey == key { return }
97
+ let key = "\(sizeStr)|\(zid)|\(stackStr)"
98
+ if lastAppliedKey == key, lastAppliedConfig?.isEqual(cfgMap) == true { return }
92
99
  lastAppliedKey = key
100
+ lastAppliedConfig = cfgMap.copy() as? NSDictionary
93
101
 
94
102
  let sellwildConfig = Self.configFromMap(cfgMap)
95
103
 
@@ -138,6 +146,10 @@ final class SellwildBannerHostView: UIView, SellwildAdViewDelegate {
138
146
  onAdFailed?(["message": error.localizedDescription])
139
147
  }
140
148
 
149
+ func sellwildAdView(_ adView: SellwildAdView, didBlockAdForZoneId zoneId: String) {
150
+ onAdBlocked?(["zoneId": zoneId])
151
+ }
152
+
141
153
  func sellwildAdView(_ adView: SellwildAdView, didRenderWithSize size: CGSize) {
142
154
  onAdResize?(["width": size.width, "height": size.height])
143
155
  }
@@ -13,6 +13,7 @@
13
13
  // SellwildFeedHostView.
14
14
  RCT_EXPORT_VIEW_PROPERTY(config, NSDictionary)
15
15
  RCT_EXPORT_VIEW_PROPERTY(scrollEnabled, BOOL)
16
+ RCT_EXPORT_VIEW_PROPERTY(consumeListingTaps, BOOL)
16
17
 
17
18
  // Direct events emitted to JS callbacks.
18
19
  RCT_EXPORT_VIEW_PROPERTY(onFeedLoaded, RCTDirectEventBlock)
@@ -13,6 +13,9 @@ import UIKit
13
13
  /// The bridge re-runs the CDN decoder against `config.remote` so
14
14
  /// feed-specific fields (COL1, bgColor, mobileZids, …) are
15
15
  /// populated even though they're not all typed on the JS side.
16
+ /// - scrollEnabled: bool — disable internal scrolling for embedding.
17
+ /// - consumeListingTaps: bool — when true the host owns listing taps;
18
+ /// the SDK does not open SFSafariViewController.
16
19
  ///
17
20
  /// Events emitted to JS:
18
21
  /// - onFeedLoaded
@@ -41,6 +44,10 @@ final class SellwildFeedHostView: UIView, SellwildFeedViewDelegate {
41
44
  /// Disable the feed's internal scrolling so it can be embedded inside a
42
45
  /// parent scroll view. Applied live and on feed creation.
43
46
  @objc var scrollEnabled: Bool = true { didSet { feedView?.scrollEnabled = scrollEnabled } }
47
+ /// When true, listing taps are only forwarded to JS (`onListingTap`) and
48
+ /// the SDK does NOT open SFSafariViewController. Read at tap time, so it
49
+ /// applies live without rebuilding the feed. Default false = SDK opens.
50
+ @objc var consumeListingTaps: Bool = false
44
51
 
45
52
  // MARK: RN events
46
53
 
@@ -61,7 +68,11 @@ final class SellwildFeedHostView: UIView, SellwildFeedViewDelegate {
61
68
  // MARK: Internals
62
69
 
63
70
  private var feedView: SellwildFeedView?
64
- private var lastAppliedKey: String?
71
+ /// Last applied `config` map, compared by value (`isEqual:` ⇒
72
+ /// `isEqualToDictionary:`, deep). NOT `NSDictionary.hash` — that is
73
+ /// just the entry count, so a partnerCode / slug / geo / remote change
74
+ /// with the same key count would be silently ignored.
75
+ private var lastAppliedConfig: NSDictionary?
65
76
  private var needsApply: Bool = false
66
77
 
67
78
  override init(frame: CGRect) {
@@ -82,9 +93,8 @@ final class SellwildFeedHostView: UIView, SellwildFeedViewDelegate {
82
93
  // Skip if the props identity hasn't changed since last apply.
83
94
  // Feed refresh is driven by user pull-to-refresh, not JS
84
95
  // re-renders.
85
- let key = "\(cfgMap.hash)"
86
- if lastAppliedKey == key { return }
87
- lastAppliedKey = key
96
+ if lastAppliedConfig?.isEqual(cfgMap) == true { return }
97
+ lastAppliedConfig = cfgMap.copy() as? NSDictionary
88
98
 
89
99
  let sellwildConfig = Self.configFromMap(cfgMap)
90
100
 
@@ -119,15 +129,12 @@ final class SellwildFeedHostView: UIView, SellwildFeedViewDelegate {
119
129
 
120
130
  func sellwildFeed(_ feed: SellwildFeedView, didTapListing listing: SellwildListing) -> Bool {
121
131
  // Forward the listing payload to JS as a plain dictionary. JS
122
- // can't return a value back through a direct event block, so
123
- // the SDK still owns whether to open the URL in
124
- // SFSafariViewController. Partners who want to fully consume
125
- // the tap can subclass via `useNativeNavigation` later;
126
- // current behaviour matches the WebView widget (always opens
127
- // in in-app browser) so there's no regression.
132
+ // can't return a value back through a direct event block (events
133
+ // are async), so whether the SDK opens SFSafariViewController is
134
+ // decided here from the `consumeListingTaps` prop instead.
128
135
  let payload = Self.listingPayload(listing)
129
136
  onListingTap?(["listing": payload])
130
- return false
137
+ return consumeListingTaps
131
138
  }
132
139
 
133
140
  func sellwildFeed(_ feed: SellwildFeedView, didRecordAdImpressionForZoneId zoneId: String) {
@@ -6,5 +6,6 @@
6
6
 
7
7
  RCT_EXTERN_METHOD(setGeo:(NSDictionary *)geo)
8
8
  RCT_EXTERN_METHOD(setExternalUserIds:(NSArray *)eids)
9
+ RCT_EXTERN_METHOD(prewarm:(NSDictionary *)config)
9
10
 
10
11
  @end
@@ -39,4 +39,18 @@ final class SellwildRNModule: NSObject {
39
39
  }
40
40
  SellwildPrebidMobile.setExternalUserIds(mapped)
41
41
  }
42
+
43
+ /// JS: `SellwildRNModule.prewarm(nativeConfig)`. Pre-initializes the native ad
44
+ /// stack before the first ad view mounts so the first impression doesn't incur
45
+ /// cold-start init latency. Idempotent. Reuses the banner manager's config
46
+ /// mapping so the payload matches `<SellwildBanner config=...>`. iOS bootstraps
47
+ /// inside `configure()` too; this is the explicit early opt-in for parity with
48
+ /// Android's `SellwildSDK.prewarm`.
49
+ @objc(prewarm:)
50
+ func prewarm(_ config: NSDictionary) {
51
+ let cfg = SellwildBannerHostView.configFromMap(config)
52
+ DispatchQueue.main.async {
53
+ _ = SellwildPrebidMobile.bootstrap(with: cfg)
54
+ }
55
+ }
42
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellwild/react-native-sdk",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "Sellwild mobile advertising SDK for React Native",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -18,16 +18,14 @@
18
18
  "advertising",
19
19
  "ads",
20
20
  "sellwild",
21
- "marketplace",
22
- "widget"
21
+ "marketplace"
23
22
  ],
24
23
  "peerDependencies": {
25
24
  "react": ">=17.0.0",
26
- "react-native": ">=0.70.0",
27
- "react-native-webview": ">=11.0.0"
25
+ "react-native": ">=0.70.0"
28
26
  },
29
27
  "dependencies": {
30
- "@sellwild/sdk-core": "^1.3.0"
28
+ "@sellwild/sdk-core": "^1.8.0"
31
29
  },
32
30
  "devDependencies": {
33
31
  "@types/react": "^18.0.0",
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
- import { Platform, requireNativeComponent, UIManager, ViewStyle, View, Text, StyleSheet, NativeSyntheticEvent } from 'react-native'
2
+ import { Platform, requireNativeComponent, UIManager, StyleProp, ViewStyle, View, Text, StyleSheet, NativeSyntheticEvent } from 'react-native'
3
3
  import { resolveAdStack, type SellwildConfig, type AdSize } from '@sellwild/sdk-core'
4
- import { resolveAppIdentity } from './appIdentity'
4
+ import { toNativeConfig } from './nativeConfig'
5
5
 
6
6
  // Standard IAB mobile ad sizes — used to lock the host View dimensions.
7
7
  // The actual ad size is also propagated to native as the `size` prop label.
@@ -60,13 +60,14 @@ interface NativeBannerProps {
60
60
  * everything else (bidders, GAM tag, etc.).
61
61
  */
62
62
  adStack: string
63
- style?: ViewStyle
63
+ style?: StyleProp<ViewStyle>
64
64
  onAdLoaded?: (e: NativeSyntheticEvent<{}>) => void
65
65
  onAdImpression?: (e: NativeSyntheticEvent<{ zoneId: string }>) => void
66
66
  onHouseAdImpression?: (e: NativeSyntheticEvent<{ zoneId: string }>) => void
67
67
  onAdClicked?: (e: NativeSyntheticEvent<{}>) => void
68
68
  onAdFailed?: (e: NativeSyntheticEvent<{ message: string }>) => void
69
69
  onAdResize?: (e: NativeSyntheticEvent<{ width: number; height: number }>) => void
70
+ onAdBlocked?: (e: NativeSyntheticEvent<{ zoneId: string }>) => void
70
71
  }
71
72
 
72
73
  const NativeBanner = (() => {
@@ -95,6 +96,12 @@ export interface SellwildBannerProps {
95
96
  onHouseImpression?: (zoneId: string) => void
96
97
  onClick?: () => void
97
98
  onError?: (error: Error) => void
99
+ /**
100
+ * Fired when Boltive blocks the rendered creative (only with
101
+ * `MOBILE_BOLTIVE_ENABLED` on). The SDK already swaps in the house ad and
102
+ * re-auctions; this is for reporting.
103
+ */
104
+ onBlocked?: (zoneId: string) => void
98
105
  }
99
106
 
100
107
  export function SellwildBanner({
@@ -106,6 +113,7 @@ export function SellwildBanner({
106
113
  onHouseImpression,
107
114
  onClick,
108
115
  onError,
116
+ onBlocked,
109
117
  }: SellwildBannerProps) {
110
118
  const dim = AD_DIMENSIONS[size]
111
119
 
@@ -159,31 +167,9 @@ export function SellwildBanner({
159
167
  )
160
168
  }
161
169
 
162
- // Pass only the fields the native banner path actually reads. The rest
163
- // of SellwildConfig is ignored on the native side. Note: TS core uses
164
- // `adRefreshInterval` (millis); the native side reads it as
165
- // `adRefreshIntervalMs`. The bridge translates.
166
- // App identity is resolved per-platform here (iOS vs Android) from the raw
167
- // CDN payload on `config.remote`; the native banner path reads these fields.
168
- const { appBundleId, appStoreUrl } = resolveAppIdentity(config)
169
- const nativeConfig = {
170
- partnerCode: config.partnerCode,
171
- appBundleId,
172
- appStoreUrl,
173
- geo: config.geo,
174
- gamTag: config.gamTag,
175
- debug: config.debug,
176
- pbsDebug: config.pbsDebug,
177
- adRefreshMax: config.adRefreshMax,
178
- adRefreshMaxMobile: config.adRefreshMaxMobile,
179
- adRefreshIntervalMs: config.adRefreshInterval,
180
- prebidServer: config.prebidServer,
181
- // Local GrowthCode overrides (remote GROWTHCODE_* keys ride `remote`).
182
- growthCode: config.growthCode,
183
- // Local localized-listings overrides (remote LOCALIZED_LISTINGS rides `remote`).
184
- localizedListings: config.localizedListings,
185
- remote: config.remote,
186
- }
170
+ // The fields the native banner path reads. Built via the shared helper so
171
+ // <SellwildBanner> and prewarm() stay in sync (single source of truth).
172
+ const nativeConfig = toNativeConfig(config)
187
173
 
188
174
  return (
189
175
  <NativeBanner
@@ -198,6 +184,7 @@ export function SellwildBanner({
198
184
  onAdImpression={() => onImpression?.()}
199
185
  onHouseAdImpression={(e: NativeSyntheticEvent<{ zoneId: string }>) => onHouseImpression?.(e.nativeEvent?.zoneId)}
200
186
  onAdClicked={() => onClick?.()}
187
+ onAdBlocked={(e: NativeSyntheticEvent<{ zoneId: string }>) => onBlocked?.(e.nativeEvent?.zoneId)}
201
188
  onAdFailed={(e: NativeSyntheticEvent<{ message: string }>) => {
202
189
  const msg = e.nativeEvent?.message ?? 'Ad failed'
203
190
  onError?.(new Error(msg))