@sellwild/react-native-sdk 1.0.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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@sellwild/react-native-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Sellwild mobile advertising SDK for React Native",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "android",
10
+ "ios"
11
+ ],
12
+ "keywords": [
13
+ "react-native",
14
+ "advertising",
15
+ "ads",
16
+ "sellwild",
17
+ "marketplace",
18
+ "widget"
19
+ ],
20
+ "peerDependencies": {
21
+ "react": ">=17.0.0",
22
+ "react-native": ">=0.70.0",
23
+ "react-native-webview": ">=11.0.0"
24
+ },
25
+ "dependencies": {
26
+ "@sellwild/sdk-core": "^1.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^18.0.0",
30
+ "@types/react-native": "^0.72.0",
31
+ "typescript": "^5.7.3"
32
+ }
33
+ }
@@ -0,0 +1,74 @@
1
+ import React, { useCallback, useState } from 'react'
2
+ import { View, StyleSheet, ViewStyle } from 'react-native'
3
+ import { WebView } from 'react-native-webview'
4
+ import type { WebViewMessageEvent } from 'react-native-webview'
5
+ import type { SellwildConfig, AdSize } from '@sellwild/sdk-core'
6
+ import { WIDGET_BASE_URL } from '@sellwild/sdk-core'
7
+ import { buildBannerHtml } from './htmlBuilder'
8
+
9
+ // Standard IAB mobile ad sizes
10
+ const AD_DIMENSIONS: Record<AdSize, { width: number; height: number }> = {
11
+ '300x250': { width: 300, height: 250 },
12
+ '320x50': { width: 320, height: 50 },
13
+ '728x90': { width: 728, height: 90 },
14
+ '160x600': { width: 160, height: 600 },
15
+ '300x600': { width: 300, height: 600 },
16
+ '1x1': { width: 1, height: 1 },
17
+ }
18
+
19
+ export interface SellwildBannerProps {
20
+ config: SellwildConfig
21
+ size: AdSize
22
+ zoneId: number | string
23
+ style?: ViewStyle
24
+ onImpression?: () => void
25
+ onClick?: () => void
26
+ onError?: (error: Error) => void
27
+ }
28
+
29
+ export function SellwildBanner({
30
+ config,
31
+ size,
32
+ zoneId,
33
+ style,
34
+ onImpression,
35
+ onClick,
36
+ onError,
37
+ }: SellwildBannerProps) {
38
+ const dimensions = AD_DIMENSIONS[size]
39
+ const html = buildBannerHtml(config, zoneId, size)
40
+
41
+ const handleMessage = useCallback(
42
+ (event: WebViewMessageEvent) => {
43
+ try {
44
+ const msg = JSON.parse(event.nativeEvent.data)
45
+ if (msg.type === 'AD_IMPRESSION') onImpression?.()
46
+ if (msg.type === 'AD_CLICK') onClick?.()
47
+ } catch {
48
+ // ignore
49
+ }
50
+ },
51
+ [onImpression, onClick]
52
+ )
53
+
54
+ return (
55
+ <View style={[{ width: dimensions.width, height: dimensions.height }, style]}>
56
+ <WebView
57
+ source={{ html, baseUrl: WIDGET_BASE_URL }}
58
+ style={StyleSheet.absoluteFill}
59
+ onMessage={handleMessage}
60
+ javaScriptEnabled
61
+ domStorageEnabled
62
+ thirdPartyCookiesEnabled
63
+ scrollEnabled={false}
64
+ showsVerticalScrollIndicator={false}
65
+ showsHorizontalScrollIndicator={false}
66
+ mixedContentMode="compatibility"
67
+ originWhitelist={['*']}
68
+ onError={syntheticEvent => {
69
+ onError?.(new Error(syntheticEvent.nativeEvent.description))
70
+ }}
71
+ />
72
+ </View>
73
+ )
74
+ }
@@ -0,0 +1,133 @@
1
+ import React from 'react'
2
+ import {
3
+ View,
4
+ Text,
5
+ Image,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ViewStyle,
9
+ } from 'react-native'
10
+ import type { SellwildListing, SellwildConfig } from '@sellwild/sdk-core'
11
+ import { currencyToSymbol } from '@sellwild/sdk-core'
12
+
13
+ export interface SellwildListingCardProps {
14
+ listing: SellwildListing
15
+ config: SellwildConfig
16
+ onPress?: (listing: SellwildListing) => void
17
+ style?: ViewStyle
18
+ }
19
+
20
+ export function SellwildListingCard({
21
+ listing,
22
+ config,
23
+ onPress,
24
+ style,
25
+ }: SellwildListingCardProps) {
26
+ const photo = listing.photos?.[0]
27
+ const price = listing.price && !isNaN(Number(listing.price))
28
+ ? Number(listing.price).toLocaleString(undefined, { maximumFractionDigits: 0 })
29
+ : null
30
+ const strikePrice = listing.strikePrice && !isNaN(Number(listing.strikePrice))
31
+ ? Number(listing.strikePrice).toLocaleString(undefined, { maximumFractionDigits: 0 })
32
+ : null
33
+ const currencySymbol = currencyToSymbol(listing.currency)
34
+ const title = listing.title?.length > 60
35
+ ? listing.title.slice(0, 60) + '...'
36
+ : listing.title
37
+
38
+ return (
39
+ <TouchableOpacity
40
+ style={[styles.card, style]}
41
+ onPress={() => onPress?.(listing)}
42
+ activeOpacity={0.85}
43
+ accessible
44
+ accessibilityLabel={title}
45
+ accessibilityRole="button"
46
+ >
47
+ {photo?.url ? (
48
+ <Image
49
+ source={{ uri: photo.url }}
50
+ style={styles.image}
51
+ resizeMode="cover"
52
+ accessibilityLabel={title}
53
+ />
54
+ ) : (
55
+ <View style={[styles.image, styles.imagePlaceholder]} />
56
+ )}
57
+
58
+ <View style={styles.overlay}>
59
+ {price && price !== '0' && (
60
+ <View style={[styles.priceBadge, { backgroundColor: config.priceColor || config.colors?.[0] || '#333' }]}>
61
+ {strikePrice && strikePrice !== price && (
62
+ <Text style={[styles.strikePrice, { color: config.priceFontColor || config.fontColor }]}>
63
+ {currencySymbol}{strikePrice}
64
+ </Text>
65
+ )}
66
+ <Text style={[styles.price, { color: config.priceFontColor || config.fontColor }]}>
67
+ {currencySymbol}{price}
68
+ </Text>
69
+ </View>
70
+ )}
71
+ </View>
72
+
73
+ <View style={styles.footer}>
74
+ <Text style={[styles.title, { fontSize: config.fontSize, color: '#222' }]} numberOfLines={2}>
75
+ {title}
76
+ </Text>
77
+ </View>
78
+ </TouchableOpacity>
79
+ )
80
+ }
81
+
82
+ const styles = StyleSheet.create({
83
+ card: {
84
+ width: 160,
85
+ borderRadius: 8,
86
+ overflow: 'hidden',
87
+ backgroundColor: '#fff',
88
+ elevation: 2,
89
+ shadowColor: '#000',
90
+ shadowOffset: { width: 0, height: 1 },
91
+ shadowOpacity: 0.12,
92
+ shadowRadius: 4,
93
+ marginBottom: 10,
94
+ },
95
+ image: {
96
+ width: '100%',
97
+ height: 160,
98
+ },
99
+ imagePlaceholder: {
100
+ backgroundColor: '#e0e0e0',
101
+ },
102
+ overlay: {
103
+ position: 'absolute',
104
+ bottom: 36,
105
+ right: 0,
106
+ },
107
+ priceBadge: {
108
+ flexDirection: 'row',
109
+ alignItems: 'center',
110
+ paddingHorizontal: 8,
111
+ paddingVertical: 4,
112
+ borderTopLeftRadius: 4,
113
+ borderBottomLeftRadius: 4,
114
+ },
115
+ price: {
116
+ fontSize: 13,
117
+ fontWeight: '600',
118
+ },
119
+ strikePrice: {
120
+ fontSize: 11,
121
+ textDecorationLine: 'line-through',
122
+ marginRight: 4,
123
+ opacity: 0.7,
124
+ },
125
+ footer: {
126
+ padding: 8,
127
+ minHeight: 36,
128
+ },
129
+ title: {
130
+ fontWeight: '400',
131
+ lineHeight: 18,
132
+ },
133
+ })
@@ -0,0 +1,145 @@
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
+ })
@@ -0,0 +1,352 @@
1
+ import type { SellwildConfig, AdSize } from '@sellwild/sdk-core'
2
+
3
+ const WIDGET_CDN = 'https://widget.sellwild.com'
4
+
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+ // Prebid.js WebView pre-configuration
7
+ //
8
+ // This script runs BEFORE prebid.js loads by enqueuing via pbjs.que.
9
+ // It addresses two critical WebView issues:
10
+ //
11
+ // 1. ortb2.app — Prebid.js running in a native WebView must declare itself as
12
+ // in-app inventory (OpenRTB app object) rather than web (site object).
13
+ // Without this, bid requests look like browser traffic; DSPs that buy app
14
+ // inventory separately will not bid, and app-ads.txt enforcement is bypassed.
15
+ //
16
+ // 2. userSync iframe filtering — Third-party cookies are blocked in all native
17
+ // WebViews (WKWebView, Android WebView). Iframe-based cookie syncs will
18
+ // always fail silently, wasting network requests and adding latency. Image
19
+ // pixel syncs may still work for some bidders. A longer syncDelay gives the
20
+ // auction time to complete before sync requests compete for bandwidth.
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ function buildPrebidPreConfigScript(config: SellwildConfig): string {
23
+ const ortb2App: Record<string, unknown> = {
24
+ publisher: { id: config.partnerCode },
25
+ }
26
+ if (config.appBundleId) ortb2App['bundle'] = config.appBundleId
27
+ if (config.appStoreUrl) ortb2App['storeurl'] = config.appStoreUrl
28
+
29
+ // GDPR / privacy signals — PBS defaults to gdpr=1 (GDPR applies) and
30
+ // blocks all bidder calls if no consent string is present. The SDK must
31
+ // explicitly declare the user's jurisdiction so the server knows whether
32
+ // to enforce consent. The host app provides gdprApplies and tcString
33
+ // via SellwildConfig; default is gdpr=0 (US traffic, not subject).
34
+ const ortb2Regs: Record<string, unknown> = {
35
+ ext: { gdpr: (config as any).gdprApplies ? 1 : 0 },
36
+ }
37
+ const ortb2User: Record<string, unknown> = {}
38
+ if ((config as any).gdprApplies && (config as any).tcString) {
39
+ ortb2User['ext'] = { consent: (config as any).tcString }
40
+ }
41
+
42
+ let s2sConfigBlock = ''
43
+ if (config.prebidServer) {
44
+ const ps = config.prebidServer
45
+ const s2s = {
46
+ accountId: ps.accountId,
47
+ bidders: ps.bidders,
48
+ timeout: ps.timeout ?? 1500,
49
+ adapter: 'prebidServer',
50
+ endpoint: { p1Consent: ps.endpoint, noP1Consent: ps.endpoint },
51
+ ...(ps.syncEndpoint
52
+ ? { syncEndpoint: { p1Consent: ps.syncEndpoint, noP1Consent: ps.syncEndpoint } }
53
+ : {}),
54
+ }
55
+ s2sConfigBlock = `,
56
+ // Route all Prebid bidder calls through Prebid Server (S2S mode).
57
+ // Solves cookie/IDFA limitations — the auction runs server-to-server.
58
+ s2sConfig: ${JSON.stringify(s2s)}`
59
+ }
60
+
61
+ return `
62
+ <script>
63
+ // Prebid.js WebView pre-configuration — runs before prebid.js initialises.
64
+ window.pbjs = window.pbjs || {};
65
+ window.pbjs.que = window.pbjs.que || [];
66
+ window.pbjs.que.push(function() {
67
+ window.pbjs.setConfig({
68
+ // Declare in-app inventory so DSPs bid on app traffic, not web traffic.
69
+ // Include regs.ext.gdpr so PBS knows whether to enforce consent.
70
+ ortb2: {
71
+ app: ${JSON.stringify(ortb2App)},
72
+ regs: ${JSON.stringify(ortb2Regs)}${Object.keys(ortb2User).length ? `,
73
+ user: ${JSON.stringify(ortb2User)}` : ''}
74
+ },
75
+ // Iframe cookie syncs always fail in WebViews — disable them.
76
+ // Image pixel syncs may still work for some bidders.
77
+ userSync: {
78
+ filterSettings: {
79
+ iframe: { bidders: '*', filter: 'exclude' }
80
+ },
81
+ syncDelay: 5000
82
+ }${s2sConfigBlock}${config.debug ? `,
83
+ // debug mode
84
+ debug: true` : ''}
85
+ });
86
+ });
87
+ </script>`
88
+ }
89
+
90
+ // ─────────────────────────────────────────────────────────────────────────────
91
+ // Config → element attributes
92
+ //
93
+ // The sellwild-widget reads configuration from HTML element attributes via
94
+ // withCustomizationsFromElement() in src/providers/Customizations/index.ts.
95
+ // Attribute names can be in any case (kebab-case, camelCase, CONSTANT_CASE).
96
+ // Complex objects are JSON-stringified; arrays are comma-separated.
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ function configToAttributes(config: SellwildConfig): string {
99
+ const parts: string[] = []
100
+
101
+ const add = (name: string, value: unknown) => {
102
+ if (value === undefined || value === null || value === '' || value === false || value === 0) return
103
+ if (typeof value === 'object') {
104
+ parts.push(`${name}='${JSON.stringify(value).replace(/'/g, '&#39;')}'`)
105
+ } else {
106
+ parts.push(`${name}="${String(value)}"`)
107
+ }
108
+ }
109
+
110
+ // Identity / listings
111
+ add('partner-code', config.partnerCode)
112
+ add('listings', config.listingsUrl)
113
+
114
+ // Disable remote customization fetch — the widget defaults to fetching
115
+ // https://widget.sellwild.com/{partnerCode}/{slug}.json which (a) doesn't
116
+ // exist for SDK consumers and (b) is unnecessary because the SDK supplies
117
+ // every customization via element attributes already.
118
+ parts.push('customize="false"')
119
+
120
+ // Ad system selection — REQUIRED. AdStack.initializeAdStack() switches on
121
+ // theme.adType and silently does nothing if it's unset, meaning Prebid never
122
+ // loads and no auctions ever run. PrebidOnly is the dominant value across
123
+ // production publisher widgets. Override via config.adType if needed.
124
+ add('ad-type', (config as any).adType || 'PrebidOnly')
125
+
126
+ // Display
127
+ add('title', config.title)
128
+ add('link-text', config.linkText)
129
+ add('buy-now-text', config.buyNowText)
130
+ add('title-color', config.titleColor)
131
+ add('title-size', config.titleSize)
132
+ add('link-color', config.linkColor)
133
+ add('link-size', config.linkSize)
134
+ add('font-size', config.fontSize)
135
+ add('font-family', config.fontFamily)
136
+ add('font-color', config.fontColor)
137
+ add('price-color', config.priceColor)
138
+ add('price-font-color', config.priceFontColor)
139
+ add('margin-bottom', config.marginBottom)
140
+ add('card-width', config.cardWidth)
141
+ add('card-height', config.cardHeight)
142
+ add('colors', config.colors?.join(','))
143
+ add('watermark', config.watermark || undefined)
144
+ add('watermark-title', config.watermarkTitle)
145
+ add('overlay-title', config.overlayTitle || undefined)
146
+ add('css', config.css)
147
+
148
+ // Ads – display
149
+ add('gam-tag', config.gamTag)
150
+ add('gam-tag-desc', config.gamTagDesc)
151
+ add('banner-zid', config.bannerZid)
152
+ add('bottom-banner-zid', config.bottomBannerZid)
153
+ add('mobile-banner-zid', config.mobileBannerZid)
154
+ // Filter empties before joining — the widget's parser splits on ',' and
155
+ // does not strip empty strings, so a stray comma yields [""] not [].
156
+ add('mobile-zid', config.mobileZids?.filter(Boolean).join(','))
157
+ add('display-zid', config.displayZids?.filter(Boolean).join(','))
158
+ add('skyscraper-zid', config.skyscraperZid)
159
+ add('hide-banner-top', config.hideBannerTop || undefined)
160
+ add('hide-banner-bottom', config.hideBannerBottom || undefined)
161
+ add('gpt-proxy-url', config.gptProxyUrl)
162
+ add('disable-gpt', config.disableGpt || undefined)
163
+ add('ad-disable-display', config.adDisableDisplay || undefined)
164
+ add('safe-frame', config.safeFrame || undefined)
165
+
166
+ // Ads – refresh
167
+ add('ad-refresh-max', config.adRefreshMax)
168
+ add('ad-refresh-max-mobile', config.adRefreshMaxMobile)
169
+ add('ad-refresh-interval', config.adRefreshInterval)
170
+ add('max-failed-auctions', config.maxFailedAuctions)
171
+ add('prebid-src', config.prebidSrc)
172
+ add('prebid-defer', config.prebidDefer)
173
+ add('floor-multiplier', config.floorMultiplier !== 1 ? config.floorMultiplier : undefined)
174
+
175
+ // Ads – geo
176
+ add('ad-geo-block', config.adGeoBlock)
177
+ add('ad-geo-block-refresh', config.adGeoBlockRefresh)
178
+
179
+ // Ads – compliance
180
+ add('gpp-enabled', config.gppEnabled || undefined)
181
+ add('tcf-version', config.tcfVersion)
182
+ add('consent-management', config.consentManagement)
183
+ add('schain-sid', config.schainSid)
184
+ add('s2s-config', config.s2sConfig)
185
+ add('iab-cats', config.iabCats?.join(','))
186
+
187
+ // Bidders (serialized as JSON — parseValue() in the widget will JSON.parse these)
188
+ add('ix', config.ix)
189
+ add('openx', config.openx)
190
+ add('pubmatic', config.pubmatic)
191
+ add('appnexus', config.appnexus)
192
+ add('rubicon', config.rubicon)
193
+ add('apstag', config.apstag)
194
+
195
+ // Waterfall
196
+ add('pub-ventures', config.pubVentures)
197
+ add('saambaa', config.saambaa)
198
+ add('opsco', config.opsco)
199
+ add('bidstream', config.bidstream)
200
+
201
+ // Third-party
202
+ add('boltive', config.boltive || undefined)
203
+ add('boltive-client-id', config.boltiveClientId)
204
+ add('lotame', config.lotame || undefined)
205
+ add('growthcode', config.growthcode)
206
+ add('bh-tag', config.bhTag)
207
+
208
+ // Debug
209
+ add('debug', config.debug || undefined)
210
+ add('membership-type', config.membershipType)
211
+
212
+ return parts.join('\n ')
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+ // Widget HTML builder
217
+ //
218
+ // The widget JS URL can be:
219
+ // - A publisher-specific bundle: https://widget.sellwild.com/{CODE}/{SLUG}.js
220
+ // (config already baked in at build time; element attributes are optional)
221
+ // - The generic bundle: https://widget.sellwild.com/partner.js
222
+ // (reads all config from element attributes — SDK default)
223
+ //
224
+ // Pass widgetJsUrl in config to override the default generic bundle.
225
+ // ─────────────────────────────────────────────────────────────────────────────
226
+ export function buildWidgetHtml(config: SellwildConfig & { widgetJsUrl?: string }): string {
227
+ // partner.js is the canonical generic widget bundle. It loads its own Prebid
228
+ // build internally (from cache.sellwild.com), so the SDK MUST NOT inject a
229
+ // separate prebid <script> tag — doing so causes a double-load and breaks
230
+ // header bidding initialization order.
231
+ const widgetSrc = config.widgetJsUrl ?? `${WIDGET_CDN}/partner.js`
232
+ const attrs = configToAttributes(config)
233
+
234
+ const prebidPreConfig = buildPrebidPreConfigScript(config)
235
+
236
+ return `<!DOCTYPE html>
237
+ <html>
238
+ <head>
239
+ <meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
241
+ <style>
242
+ * { box-sizing: border-box; margin: 0; padding: 0; }
243
+ html, body { width: 100%; background: transparent; overflow-x: hidden; }
244
+ </style>
245
+ ${prebidPreConfig}
246
+ </head>
247
+ <body>
248
+ <sellwild-widget
249
+ ${attrs}
250
+ ></sellwild-widget>
251
+
252
+ <script>
253
+ (function() {
254
+ function send(type, payload) {
255
+ try {
256
+ window.ReactNativeWebView.postMessage(JSON.stringify(Object.assign({ type: type }, payload || {})));
257
+ } catch(e) {}
258
+ }
259
+
260
+ // partner/index.tsx calls window.open() on listing tap — intercept it
261
+ var _open = window.open;
262
+ window.open = function(url) {
263
+ if (url && (url.indexOf('itemDetail') !== -1 || url.indexOf('sellwild.com') !== -1)) {
264
+ send('LISTING_CLICK', { url: url });
265
+ return null;
266
+ }
267
+ return _open.apply(window, arguments);
268
+ };
269
+
270
+ document.addEventListener('DOMContentLoaded', function() {
271
+ setTimeout(function() { send('WIDGET_LOADED'); }, 600);
272
+ });
273
+
274
+ window.addEventListener('error', function(e) {
275
+ send('ERROR', { message: e.message || 'Widget load error' });
276
+ });
277
+ })();
278
+ </script>
279
+
280
+ <script async src="${widgetSrc}"></script>
281
+ </body>
282
+ </html>`
283
+ }
284
+
285
+ // ─────────────────────────────────────────────────────────────────────────────
286
+ // Banner HTML builder
287
+ // ─────────────────────────────────────────────────────────────────────────────
288
+ export function buildBannerHtml(
289
+ config: SellwildConfig,
290
+ zoneId: number | string,
291
+ size: AdSize
292
+ ): string {
293
+ const [width, height] = size.split('x').map(Number)
294
+ const gptSrc = config.gptProxyUrl
295
+ ? `${config.gptProxyUrl}/tag/js/gpt.js`
296
+ : 'https://securepubads.g.doubleclick.net/tag/js/gpt.js'
297
+
298
+ const adScript = config.gamTag && !config.disableGpt
299
+ ? buildGptScript(config.gamTag, gptSrc, width, height)
300
+ : buildZoneScript(String(zoneId), width, height)
301
+
302
+ return `<!DOCTYPE html>
303
+ <html>
304
+ <head>
305
+ <meta charset="utf-8">
306
+ <meta name="viewport" content="width=device-width, initial-scale=1">
307
+ <style>
308
+ * { box-sizing: border-box; margin: 0; padding: 0; }
309
+ html, body { width: ${width}px; height: ${height}px; overflow: hidden; background: transparent; }
310
+ #ad { width: ${width}px; height: ${height}px; }
311
+ </style>
312
+ </head>
313
+ <body>
314
+ <div id="ad"></div>
315
+ <script>
316
+ function notify(type) {
317
+ try { window.ReactNativeWebView.postMessage(JSON.stringify({ type: type })); } catch(e) {}
318
+ }
319
+ ${adScript}
320
+ </script>
321
+ </body>
322
+ </html>`
323
+ }
324
+
325
+ function buildGptScript(gamTag: string, gptSrc: string, w: number, h: number): string {
326
+ return `
327
+ window.googletag = window.googletag || { cmd: [] };
328
+ var s = document.createElement('script');
329
+ s.src = '${gptSrc}'; s.async = true;
330
+ document.head.appendChild(s);
331
+ googletag.cmd.push(function() {
332
+ var slot = googletag.defineSlot('${gamTag}', [${w}, ${h}], 'ad');
333
+ if (slot) {
334
+ slot.addService(googletag.pubads());
335
+ googletag.pubads().enableSingleRequest();
336
+ googletag.pubads().addEventListener('slotRenderEnded', function(e) {
337
+ if (!e.isEmpty) notify('AD_IMPRESSION');
338
+ });
339
+ googletag.enableServices();
340
+ googletag.display('ad');
341
+ }
342
+ });`
343
+ }
344
+
345
+ function buildZoneScript(zoneId: string, w: number, h: number): string {
346
+ return `
347
+ var s = document.createElement('script');
348
+ s.src = 'https://bidstream.sellwild.com/ads?zone=${zoneId}&w=${w}&h=${h}';
349
+ s.async = true;
350
+ s.onload = function() { notify('AD_IMPRESSION'); };
351
+ document.getElementById('ad').appendChild(s);`
352
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ export { SellwildWidget } from './SellwildWidget'
2
+ export type { SellwildWidgetProps } from './SellwildWidget'
3
+
4
+ export { SellwildBanner } from './SellwildBanner'
5
+ export type { SellwildBannerProps } from './SellwildBanner'
6
+
7
+ export { SellwildListingCard } from './SellwildListingCard'
8
+ export type { SellwildListingCardProps } from './SellwildListingCard'
9
+
10
+ export { useSellwildListings } from './useSellwildListings'
11
+ export type { UseSellwildListingsResult } from './useSellwildListings'
12
+
13
+ // Re-export core types for convenience
14
+ export type {
15
+ SellwildConfig,
16
+ PartialSellwildConfig,
17
+ SellwildListing,
18
+ SellwildPhoto,
19
+ AdSize,
20
+ AdPlacement,
21
+ AdPlacementType,
22
+ } from '@sellwild/sdk-core'
23
+
24
+ export { buildConfig, currencyToSymbol } from '@sellwild/sdk-core'
@@ -0,0 +1,54 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+ import type { SellwildConfig, SellwildListing, SellwildListingsResponse } from '@sellwild/sdk-core'
3
+ import { fetchListings, clearListingCache } from '@sellwild/sdk-core'
4
+
5
+ export interface UseSellwildListingsResult {
6
+ listings: SellwildListing[]
7
+ config: Record<string, unknown>
8
+ loading: boolean
9
+ error: Error | null
10
+ refresh: () => void
11
+ }
12
+
13
+ export function useSellwildListings(sdkConfig: SellwildConfig): UseSellwildListingsResult {
14
+ const [listings, setListings] = useState<SellwildListing[]>([])
15
+ const [config, setConfig] = useState<Record<string, unknown>>({})
16
+ const [loading, setLoading] = useState(true)
17
+ const [error, setError] = useState<Error | null>(null)
18
+ const [refreshKey, setRefreshKey] = useState(0)
19
+
20
+ const refresh = useCallback(() => setRefreshKey(k => k + 1), [])
21
+
22
+ useEffect(() => {
23
+ let cancelled = false
24
+ const controller = new AbortController()
25
+
26
+ setLoading(true)
27
+ setError(null)
28
+
29
+ // Clear cache so refresh actually re-fetches from the network
30
+ if (refreshKey > 0) clearListingCache()
31
+
32
+ fetchListings(sdkConfig, { signal: controller.signal })
33
+ .then((result: SellwildListingsResponse) => {
34
+ if (cancelled) return
35
+ setListings(result.listings)
36
+ setConfig(result.config)
37
+ })
38
+ .catch((err: Error) => {
39
+ if (cancelled) return
40
+ if (err.name === 'AbortError') return
41
+ setError(err)
42
+ })
43
+ .finally(() => {
44
+ if (!cancelled) setLoading(false)
45
+ })
46
+
47
+ return () => {
48
+ cancelled = true
49
+ controller.abort()
50
+ }
51
+ }, [sdkConfig.listingsUrl, refreshKey])
52
+
53
+ return { listings, config, loading, error, refresh }
54
+ }