@sellwild/react-native-sdk 1.7.4 → 1.8.1

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.
@@ -23,14 +23,15 @@ import { resolveAppIdentity } from './appIdentity'
23
23
  // iOS: SellwildFeedView (UITableView-backed)
24
24
  // Android: com.sellwild.sdk.SellwildFeedView (RecyclerView-backed)
25
25
  //
26
- // This component is the drop-in native replacement for the WebView-based
27
- // <SellwildWidget>. Same one-component integration shape; native rails.
26
+ // This is the one-component marketplace surface for React Native (the
27
+ // WebView-based <SellwildWidget> has been removed).
28
28
 
29
29
  const NATIVE_NAME = 'SellwildFeedView'
30
30
 
31
31
  interface NativeFeedProps {
32
32
  config: object
33
33
  scrollEnabled?: boolean
34
+ consumeListingTaps?: boolean
34
35
  style?: StyleProp<ViewStyle>
35
36
  onFeedLoaded?: (e: NativeSyntheticEvent<{}>) => void
36
37
  onFeedReady?: (e: NativeSyntheticEvent<{ listingCount: number }>) => void
@@ -95,9 +96,23 @@ export interface SellwildFeedProps {
95
96
  onFeedReady?: (listingCount: number) => void
96
97
 
97
98
  /**
98
- * Fired when a listing card is tapped. Return `true` to consume the
99
- * event; return `false` (or omit) to let the SDK open `listing.url`
100
- * in the platform in-app browser (Custom Tabs / SFSafariViewController).
99
+ * When `true`, listing taps are fully handled by the host app: the SDK does
100
+ * NOT open the listing in the in-app browser (Custom Tabs /
101
+ * SFSafariViewController) and only fires `onListingTap`. Use this to route
102
+ * listings through your own navigation. Defaults to `false` (the SDK opens
103
+ * the listing URL), so existing integrations are unaffected.
104
+ */
105
+ consumeListingTaps?: boolean
106
+
107
+ /**
108
+ * Fired when a listing card is tapped. This is a notification only: the
109
+ * return value is ignored, because React Native events are delivered to JS
110
+ * asynchronously — after the native side has already decided whether to
111
+ * open the browser. To handle navigation yourself, set
112
+ * `consumeListingTaps` instead.
113
+ *
114
+ * The `boolean` return type is kept only so existing handlers still compile;
115
+ * it is deprecated and has no effect.
101
116
  */
102
117
  onListingTap?: (listing: SellwildListing) => boolean | void
103
118
 
@@ -117,10 +132,22 @@ export interface SellwildFeedProps {
117
132
  onError?: (error: Error) => void
118
133
  }
119
134
 
135
+ /**
136
+ * Zone ids are `number | string` in core config, defaulting to `0` (unset).
137
+ * Both native bridges read them as strings, so send a string, or nothing when
138
+ * unset: Android threw on a number and iOS silently dropped it.
139
+ */
140
+ function zoneIdProp(v: number | string | null | undefined): string | undefined {
141
+ if (v === null || v === undefined) return undefined
142
+ const s = String(v).trim()
143
+ return s === '' || s === '0' ? undefined : s
144
+ }
145
+
120
146
  export function SellwildFeed({
121
147
  config,
122
148
  style,
123
149
  scrollEnabled = true,
150
+ consumeListingTaps = false,
124
151
  onContentSizeChange,
125
152
  onLoad,
126
153
  onFeedReady,
@@ -134,6 +161,8 @@ export function SellwildFeed({
134
161
  // scrolling, so we size our own container from the native-reported content
135
162
  // height rather than filling with flex:1.
136
163
  const [contentHeight, setContentHeight] = React.useState<number | null>(null)
164
+ // Dev-only: warn once per feed about the ignored `return true`, not on every tap.
165
+ const warnedReturnTrue = React.useRef(false)
137
166
  const embedded = scrollEnabled === false
138
167
  const containerStyle: StyleProp<ViewStyle> = embedded
139
168
  ? [contentHeight != null ? { height: contentHeight } : undefined, style]
@@ -186,9 +215,9 @@ export function SellwildFeed({
186
215
  remote: config.remote,
187
216
  listingsUrl: config.listingsUrl,
188
217
  priceColor: config.priceColor,
189
- bannerZid: config.bannerZid,
190
- bottomBannerZid: config.bottomBannerZid,
191
- mobileBannerZid: config.mobileBannerZid,
218
+ bannerZid: zoneIdProp(config.bannerZid),
219
+ bottomBannerZid: zoneIdProp(config.bottomBannerZid),
220
+ mobileBannerZid: zoneIdProp(config.mobileBannerZid),
192
221
  mobileZids: config.mobileZids,
193
222
  }
194
223
 
@@ -197,6 +226,7 @@ export function SellwildFeed({
197
226
  style={containerStyle}
198
227
  config={nativeConfig}
199
228
  scrollEnabled={scrollEnabled}
229
+ consumeListingTaps={consumeListingTaps}
200
230
  onContentSizeChange={(e: NativeSyntheticEvent<{ width?: number; height: number }>) => {
201
231
  const { width, height } = e.nativeEvent ?? { height: 0 }
202
232
  // Only size our own container when embedded; a scrolling feed fills
@@ -209,7 +239,15 @@ export function SellwildFeed({
209
239
  onFeedReady?.(e.nativeEvent.listingCount)
210
240
  }}
211
241
  onListingTap={(e: NativeSyntheticEvent<{ listing: SellwildListing }>) => {
212
- onListingTap?.(e.nativeEvent.listing)
242
+ const result = onListingTap?.(e.nativeEvent.listing)
243
+ if (__DEV__ && result === true && !consumeListingTaps && !warnedReturnTrue.current) {
244
+ warnedReturnTrue.current = true
245
+ console.warn(
246
+ '[Sellwild] onListingTap returned true, but the return value is ignored ' +
247
+ '(RN events are async). Set consumeListingTaps on <SellwildFeed> to ' +
248
+ 'stop the SDK from opening the listing.',
249
+ )
250
+ }
213
251
  }}
214
252
  onAdImpression={(e: NativeSyntheticEvent<{ zoneId: string }>) => {
215
253
  onAdImpression?.(e.nativeEvent.zoneId)
@@ -10,14 +10,13 @@ import type { SellwildConfig } from '@sellwild/sdk-core'
10
10
  * (OS-agnostic) keys onto `appBundleId`/`appStoreUrl` and stashes the raw
11
11
  * payload on `config.remote`; the platform is only known here on the device,
12
12
  * so we resolve the OS-specific value at the bridge boundary — the RN analog
13
- * of the native `SellwildSDK.apply()` (iOS/Android) and Flutter `dart:io`
14
- * resolution.
13
+ * of the native `SellwildSDK.apply()` (iOS/Android) resolution.
15
14
  *
16
15
  * Both native bridges (feed + banner) consume `config.appBundleId` /
17
16
  * `config.appStoreUrl`, so resolving once here makes both correct without any
18
17
  * Kotlin/Swift bridge change.
19
18
  *
20
- * Precedence (matches iOS/Android `apply()` and Flutter): the per-platform CDN
19
+ * Precedence (matches iOS/Android `apply()`): the per-platform CDN
21
20
  * value, else the shared `appBundleId`/`appStoreUrl` (which already carries any
22
21
  * host override). Backward compatible: no suffixed key = today's behavior.
23
22
  */
package/src/commands.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { NativeModules } from 'react-native'
2
- import type { SellwildEid, SellwildGeo } from '@sellwild/sdk-core'
2
+ import { configure as coreConfigure } from '@sellwild/sdk-core'
3
+ import type { SellwildConfig, SellwildEid, SellwildGeo } from '@sellwild/sdk-core'
4
+ import { toNativeConfig } from './nativeConfig'
3
5
 
4
6
  // Imperative bridge to the native Sellwild method module. The ad surface is
5
7
  // otherwise view-manager-only (config flows as a prop on <SellwildBanner> /
@@ -8,6 +10,7 @@ const SellwildRNModule = NativeModules.SellwildRNModule as
8
10
  | {
9
11
  setGeo?: (geo: Record<string, unknown>) => void
10
12
  setExternalUserIds?: (eids: SellwildEid[]) => void
13
+ prewarm?: (config: Record<string, unknown>) => void
11
14
  }
12
15
  | undefined
13
16
 
@@ -35,3 +38,29 @@ export function setGeo(geo: SellwildGeo | null): void {
35
38
  export function setExternalUserIds(eids: SellwildEid[]): void {
36
39
  SellwildRNModule?.setExternalUserIds?.(eids ?? [])
37
40
  }
41
+
42
+ /**
43
+ * Pre-initialize the native ad stack (Prebid + ad server SDK) before the first
44
+ * `<SellwildBanner>`/`<SellwildFeed>` mounts, so the first impression doesn't
45
+ * incur cold-start init latency and fall back to server-only demand.
46
+ *
47
+ * Since 1.8.0 `configure()` calls this automatically, so it's only needed for
48
+ * configs built another way (e.g. `buildConfig`). Idempotent, and a no-op if the
49
+ * native module isn't linked.
50
+ */
51
+ export function prewarm(config: SellwildConfig): void {
52
+ SellwildRNModule?.prewarm?.(toNativeConfig(config))
53
+ }
54
+
55
+ /**
56
+ * Fetch the partner's CDN config (see core `configure`) and pre-warm the native
57
+ * ad stack with it, so the first ad view doesn't pay SDK init latency. Matches
58
+ * native iOS / Android `configure()`, which pre-warm too.
59
+ */
60
+ export async function configure(
61
+ ...args: Parameters<typeof coreConfigure>
62
+ ): Promise<SellwildConfig> {
63
+ const config = await coreConfigure(...args)
64
+ prewarm(config)
65
+ return config
66
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,3 @@
1
- export { SellwildWidget } from './SellwildWidget'
2
- export type { SellwildWidgetProps } from './SellwildWidget'
3
-
4
1
  export { SellwildBanner } from './SellwildBanner'
5
2
  export type { SellwildBannerProps } from './SellwildBanner'
6
3
 
@@ -14,7 +11,7 @@ export { useSellwildListings } from './useSellwildListings'
14
11
  export type { UseSellwildListingsResult } from './useSellwildListings'
15
12
 
16
13
  // Imperative native setters (runtime, session-scoped).
17
- export { setGeo, setExternalUserIds } from './commands'
14
+ export { setGeo, setExternalUserIds, prewarm, configure } from './commands'
18
15
 
19
16
  // Re-export core types for convenience
20
17
  export type {
@@ -34,7 +31,6 @@ export type {
34
31
  } from '@sellwild/sdk-core'
35
32
 
36
33
  export {
37
- configure,
38
34
  buildConfig,
39
35
  buildConfigWithRemote,
40
36
  currencyToSymbol,
@@ -43,3 +39,9 @@ export {
43
39
  } from '@sellwild/sdk-core'
44
40
 
45
41
  export type { ConfigureOptions } from '@sellwild/sdk-core'
42
+
43
+ // Stamp every analytics event with the host platform. Runs once on module load
44
+ // so any code path that reaches the shared core `eventQueue` is attributed to
45
+ // react-native (merged into `attributes.platform` alongside `sdkVersion`).
46
+ import { eventQueue } from '@sellwild/sdk-core'
47
+ eventQueue.setPlatform('react-native')
@@ -0,0 +1,34 @@
1
+ import type { SellwildConfig } from '@sellwild/sdk-core'
2
+ import { resolveAppIdentity } from './appIdentity'
3
+
4
+ /**
5
+ * Build the native config payload the auction/ad path reads, from a core
6
+ * SellwildConfig. This is the single source of truth for the fields the native
7
+ * banner path + `prewarm` consume — keep `<SellwildBanner>` and `prewarm()` in
8
+ * sync by both going through here.
9
+ *
10
+ * Notes:
11
+ * - App identity is resolved per-platform (iOS vs Android) from `config.remote`.
12
+ * - TS core uses `adRefreshInterval` (millis); the native side reads it as
13
+ * `adRefreshIntervalMs`. The bridge translates.
14
+ * - The raw CDN payload rides on `remote` for the auction passthrough.
15
+ */
16
+ export function toNativeConfig(config: SellwildConfig): Record<string, unknown> {
17
+ const { appBundleId, appStoreUrl } = resolveAppIdentity(config)
18
+ return {
19
+ partnerCode: config.partnerCode,
20
+ appBundleId,
21
+ appStoreUrl,
22
+ geo: config.geo,
23
+ gamTag: config.gamTag,
24
+ debug: config.debug,
25
+ pbsDebug: config.pbsDebug,
26
+ adRefreshMax: config.adRefreshMax,
27
+ adRefreshMaxMobile: config.adRefreshMaxMobile,
28
+ adRefreshIntervalMs: config.adRefreshInterval,
29
+ prebidServer: config.prebidServer,
30
+ growthCode: config.growthCode,
31
+ localizedListings: config.localizedListings,
32
+ remote: config.remote,
33
+ }
34
+ }
@@ -1,145 +0,0 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import {
3
- View,
4
- StyleSheet,
5
- ActivityIndicator,
6
- ViewStyle,
7
- } from 'react-native'
8
- import { WebView } from 'react-native-webview'
9
- import type { WebViewMessageEvent } from 'react-native-webview'
10
- import type { SellwildConfig, SellwildListing } from '@sellwild/sdk-core'
11
- import { buildConfig, WIDGET_BASE_URL } from '@sellwild/sdk-core'
12
- import type { PartialSellwildConfig } from '@sellwild/sdk-core'
13
- import { buildWidgetHtml } from './htmlBuilder'
14
-
15
- export interface SellwildWidgetProps {
16
- config: PartialSellwildConfig
17
- style?: ViewStyle
18
- onListingPress?: (listing: SellwildListing) => void
19
- onAdImpression?: (zoneId: string | number) => void
20
- onError?: (error: Error) => void
21
- onLoad?: () => void
22
- }
23
-
24
- // The web widget intercepts window.open() and sends the listing URL.
25
- // A full SellwildListing object is not available from the WebView boundary —
26
- // the listing field will be absent; url contains the Sellwild product page URL.
27
- type WebViewMessage =
28
- | { type: 'LISTING_CLICK'; listing?: SellwildListing; url?: string }
29
- | { type: 'AD_IMPRESSION'; zoneId: string | number }
30
- | { type: 'WIDGET_LOADED' }
31
- | { type: 'ERROR'; message: string }
32
-
33
- export function SellwildWidget({
34
- config: configProp,
35
- style,
36
- onListingPress,
37
- onAdImpression,
38
- onError,
39
- onLoad,
40
- }: SellwildWidgetProps) {
41
- // Memoize so that parent re-renders don't change `source.html`, which
42
- // would force react-native-webview to reload and lose ad session state.
43
- const config = useMemo(() => buildConfig(configProp), [
44
- configProp.partnerCode,
45
- configProp.listingsUrl,
46
- configProp.gamTag,
47
- configProp.gptProxyUrl,
48
- configProp.bannerZid,
49
- configProp.debug,
50
- ])
51
- const [loading, setLoading] = useState(true)
52
- const webViewRef = useRef<WebView>(null)
53
-
54
- const html = useMemo(() => buildWidgetHtml(config), [config])
55
-
56
- const handleMessage = useCallback(
57
- (event: WebViewMessageEvent) => {
58
- try {
59
- const msg: WebViewMessage = JSON.parse(event.nativeEvent.data)
60
- switch (msg.type) {
61
- case 'LISTING_CLICK':
62
- // The web widget sends a URL via window.open() interception.
63
- // A full listing object is not available at the WebView boundary.
64
- // Pass the listing if somehow present (future compatibility),
65
- // otherwise construct a minimal stub so callers can navigate.
66
- if (msg.listing) {
67
- onListingPress?.(msg.listing)
68
- } else if (msg.url && onListingPress) {
69
- const stub: SellwildListing = {
70
- id: '', status: 'active', title: '',
71
- text: '', url: msg.url, categoryId: '', categoryGroupId: '',
72
- currency: 'USD', price: '', strikePrice: '',
73
- has_photo: false, photo_count: 0, photos: [],
74
- createdDate: '', videoUrl: '', shippable: '',
75
- listingType: '', dataSourceId: '',
76
- user: { id: '', firstName: '', lastName: '', username: '',
77
- membershipType: '', trustLevel: '', has_photo: '',
78
- photos: [], isPhoneVerified: false },
79
- }
80
- onListingPress(stub)
81
- }
82
- break
83
- case 'AD_IMPRESSION':
84
- onAdImpression?.(msg.zoneId)
85
- break
86
- case 'WIDGET_LOADED':
87
- setLoading(false)
88
- onLoad?.()
89
- break
90
- case 'ERROR':
91
- onError?.(new Error(msg.message))
92
- break
93
- }
94
- } catch {
95
- // Non-SDK message, ignore
96
- }
97
- },
98
- [onListingPress, onAdImpression, onError, onLoad]
99
- )
100
-
101
- return (
102
- <View style={[styles.container, style]}>
103
- {loading && (
104
- <View style={styles.loader}>
105
- <ActivityIndicator size="large" />
106
- </View>
107
- )}
108
- <WebView
109
- ref={webViewRef}
110
- source={{ html, baseUrl: 'https://widget.sellwild.com/' }}
111
- style={styles.webView}
112
- onMessage={handleMessage}
113
- javaScriptEnabled
114
- domStorageEnabled
115
- thirdPartyCookiesEnabled
116
- allowsInlineMediaPlayback
117
- mediaPlaybackRequiresUserAction={false}
118
- mixedContentMode="compatibility"
119
- originWhitelist={['*']}
120
- onError={syntheticEvent => {
121
- const { nativeEvent } = syntheticEvent
122
- onError?.(new Error(nativeEvent.description))
123
- }}
124
- />
125
- </View>
126
- )
127
- }
128
-
129
- const styles = StyleSheet.create({
130
- container: {
131
- flex: 1,
132
- minHeight: 300,
133
- },
134
- webView: {
135
- flex: 1,
136
- backgroundColor: 'transparent',
137
- },
138
- loader: {
139
- ...StyleSheet.absoluteFillObject,
140
- justifyContent: 'center',
141
- alignItems: 'center',
142
- backgroundColor: '#f5f5f5',
143
- zIndex: 1,
144
- },
145
- })