@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 +33 -0
- package/src/SellwildBanner.tsx +74 -0
- package/src/SellwildListingCard.tsx +133 -0
- package/src/SellwildWidget.tsx +145 -0
- package/src/htmlBuilder.ts +352 -0
- package/src/index.ts +24 -0
- package/src/useSellwildListings.ts +54 -0
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, ''')}'`)
|
|
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
|
+
}
|