@bigcrunch/react-native-ads 0.11.0 → 0.14.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.
Files changed (33) hide show
  1. package/android/bigcrunch-ads/com/bigcrunch/ads/BigCrunchAds.kt +21 -4
  2. package/android/bigcrunch-ads/com/bigcrunch/ads/adapters/GoogleAdsAdapter.kt +8 -1
  3. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AdOrchestrator.kt +37 -12
  4. package/android/bigcrunch-ads/com/bigcrunch/ads/core/AnalyticsClient.kt +213 -46
  5. package/android/bigcrunch-ads/com/bigcrunch/ads/core/BidRequestClient.kt +52 -17
  6. package/android/bigcrunch-ads/com/bigcrunch/ads/core/DeviceContext.kt +1 -1
  7. package/android/bigcrunch-ads/com/bigcrunch/ads/models/AdEvent.kt +81 -2
  8. package/android/bigcrunch-ads/com/bigcrunch/ads/models/PlacementConfig.kt +4 -1
  9. package/android/src/main/java/com/bigcrunch/ads/react/BigCrunchAdsModule.kt +57 -2
  10. package/ios/BigCrunchAds/Sources/Adapters/GoogleAdsAdapter.swift +7 -0
  11. package/ios/BigCrunchAds/Sources/BigCrunchAds.swift +29 -6
  12. package/ios/BigCrunchAds/Sources/BigCrunchRewarded.swift +10 -2
  13. package/ios/BigCrunchAds/Sources/Core/AdOrchestrator.swift +20 -4
  14. package/ios/BigCrunchAds/Sources/Core/AnalyticsClient.swift +193 -84
  15. package/ios/BigCrunchAds/Sources/Core/BidRequestClient.swift +54 -17
  16. package/ios/BigCrunchAds/Sources/Core/DeviceContext.swift +1 -1
  17. package/ios/BigCrunchAds/Sources/Models/AdEvent.swift +111 -3
  18. package/ios/BigCrunchAds/Sources/Models/PlacementConfig.swift +4 -1
  19. package/ios/BigCrunchAdsModule.m +6 -0
  20. package/ios/BigCrunchAdsModule.swift +39 -2
  21. package/ios/BigCrunchBannerViewManager.swift +1 -0
  22. package/lib/BigCrunchAds.d.ts +33 -2
  23. package/lib/BigCrunchAds.d.ts.map +1 -1
  24. package/lib/BigCrunchAds.js +35 -2
  25. package/lib/NativeBigCrunchAds.d.ts +3 -2
  26. package/lib/NativeBigCrunchAds.d.ts.map +1 -1
  27. package/lib/types/index.d.ts +40 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/react-native-bigcrunch-ads.podspec +1 -1
  31. package/src/BigCrunchAds.ts +41 -2
  32. package/src/NativeBigCrunchAds.ts +5 -2
  33. package/src/types/index.ts +43 -0
@@ -4,6 +4,7 @@ import com.bigcrunch.ads.BigCrunchAds
4
4
  import com.bigcrunch.ads.internal.BCLogger
5
5
  import com.bigcrunch.ads.internal.HttpClient
6
6
  import com.bigcrunch.ads.internal.PrivacyStore
7
+ import com.bigcrunch.ads.models.AuctionData
7
8
  import com.bigcrunch.ads.models.PlacementConfig
8
9
  import com.bigcrunch.ads.models.S2SConfig
9
10
  import kotlinx.coroutines.*
@@ -37,9 +38,15 @@ internal class BidRequestClient(
37
38
  private const val BATCH_DELAY_MS = 100L
38
39
  }
39
40
 
41
+ /** Bid response containing both targeting KVPs and auction metadata */
42
+ internal data class BidResponse(
43
+ val targeting: Map<String, String>,
44
+ val auctionData: AuctionData
45
+ )
46
+
40
47
  private class PendingRequest(
41
48
  val placement: PlacementConfig,
42
- val deferred: CompletableDeferred<Map<String, String>?>
49
+ val deferred: CompletableDeferred<BidResponse?>
43
50
  )
44
51
 
45
52
  // MARK: - Public API
@@ -53,13 +60,13 @@ internal class BidRequestClient(
53
60
  * @param placement The placement to fetch demand for
54
61
  * @return Targeting KVPs to apply to GAM request, or null
55
62
  */
56
- suspend fun fetchDemand(placement: PlacementConfig): Map<String, String>? {
63
+ suspend fun fetchDemand(placement: PlacementConfig): BidResponse? {
57
64
  if (!s2sConfig.enabled) {
58
65
  BCLogger.d(TAG, "S2S is disabled, skipping bid request")
59
66
  return null
60
67
  }
61
68
 
62
- val deferred = CompletableDeferred<Map<String, String>?>()
69
+ val deferred = CompletableDeferred<BidResponse?>()
63
70
 
64
71
  mutex.withLock {
65
72
  pendingRequests.add(PendingRequest(placement, deferred))
@@ -94,8 +101,8 @@ internal class BidRequestClient(
94
101
 
95
102
  // Distribute results to each pending deferred
96
103
  for (pending in batch) {
97
- val targeting = results[pending.placement.placementId]
98
- pending.deferred.complete(targeting)
104
+ val bidResponse = results[pending.placement.placementId]
105
+ pending.deferred.complete(bidResponse)
99
106
  }
100
107
  }
101
108
 
@@ -103,7 +110,7 @@ internal class BidRequestClient(
103
110
 
104
111
  private suspend fun executeBidRequest(
105
112
  placements: List<PlacementConfig>
106
- ): Map<String, Map<String, String>> {
113
+ ): Map<String, BidResponse> {
107
114
  val requestBody = buildRequestJSON(placements)
108
115
  val jsonString = requestBody.toString()
109
116
 
@@ -118,7 +125,7 @@ internal class BidRequestClient(
118
125
  return when {
119
126
  result.isSuccess -> {
120
127
  val responseJson = result.getOrNull()!!
121
- parseResponse(responseJson)
128
+ parseResponse(responseJson, placements)
122
129
  }
123
130
  else -> {
124
131
  BCLogger.e(TAG, "Bid request failed: ${result.exceptionOrNull()?.message}")
@@ -302,16 +309,29 @@ internal class BidRequestClient(
302
309
  *
303
310
  * Groups bids by impid, picks winner (highest price), copies ext.targeting.
304
311
  */
305
- private fun parseResponse(jsonString: String): Map<String, Map<String, String>> {
312
+ private fun parseResponse(jsonString: String, placements: List<PlacementConfig>): Map<String, BidResponse> {
306
313
  return try {
307
314
  val json = JSONObject(jsonString)
308
315
  val seatbids = json.optJSONArray("seatbid") ?: return emptyMap()
309
316
 
317
+ val auctionId = json.optString("id", "")
318
+
319
+ // Build floor price lookup from placement configs
320
+ val floorPrices = placements.associate { it.placementId to it.floorPrice }
321
+
310
322
  // Collect all bids grouped by impid
311
- val bidsByImpId = mutableMapOf<String, MutableList<Pair<Double, Map<String, String>>>>()
323
+ data class BidInfo(
324
+ val price: Double,
325
+ val targeting: Map<String, String>,
326
+ val seat: String,
327
+ val creativeId: String?
328
+ )
329
+
330
+ val bidsByImpId = mutableMapOf<String, MutableList<BidInfo>>()
312
331
 
313
332
  for (i in 0 until seatbids.length()) {
314
333
  val seatbid = seatbids.getJSONObject(i)
334
+ val seat = seatbid.optString("seat", "")
315
335
  val bids = seatbid.optJSONArray("bid") ?: continue
316
336
 
317
337
  for (j in 0 until bids.length()) {
@@ -320,6 +340,7 @@ internal class BidRequestClient(
320
340
  val price = bid.optDouble("price", 0.0)
321
341
  val ext = bid.optJSONObject("ext") ?: continue
322
342
  val targetingJson = ext.optJSONObject("targeting") ?: continue
343
+ val crid = bid.optString("crid", null)
323
344
 
324
345
  val targeting = mutableMapOf<String, String>()
325
346
  val keys = targetingJson.keys()
@@ -330,19 +351,33 @@ internal class BidRequestClient(
330
351
 
331
352
  if (impid.isNotEmpty()) {
332
353
  bidsByImpId.getOrPut(impid) { mutableListOf() }
333
- .add(Pair(price, targeting))
354
+ .add(BidInfo(price, targeting, seat, crid))
334
355
  }
335
356
  }
336
357
  }
337
358
 
338
- // Pick winner (highest price) for each impid
339
- val results = mutableMapOf<String, Map<String, String>>()
359
+ // Pick winner (highest price) for each impid and compute auction metadata
360
+ val results = mutableMapOf<String, BidResponse>()
340
361
  for ((impid, bids) in bidsByImpId) {
341
- val winner = bids.maxByOrNull { it.first }
342
- if (winner != null) {
343
- results[impid] = winner.second
344
- BCLogger.d(TAG, "Winner for $impid: $${winner.first}")
345
- }
362
+ val sortedBids = bids.sortedByDescending { it.price }
363
+ val winner = sortedBids.first()
364
+
365
+ val secondHighestBid = if (sortedBids.size > 1) sortedBids[1].price else null
366
+ val floorPrice = floorPrices[impid]
367
+
368
+ val auctionData = AuctionData(
369
+ auctionId = auctionId.ifEmpty { null },
370
+ bidder = winner.seat,
371
+ bidPriceCpm = winner.price,
372
+ creativeId = winner.creativeId,
373
+ gamPriceBucket = winner.targeting["hb_pb"],
374
+ floorPrice = floorPrice,
375
+ secondHighestBid = secondHighestBid,
376
+ demandChannel = "S2S"
377
+ )
378
+
379
+ results[impid] = BidResponse(winner.targeting, auctionData)
380
+ BCLogger.d(TAG, "Winner for $impid: $${winner.price}")
346
381
  }
347
382
 
348
383
  BCLogger.i(TAG, "Parsed ${results.size} winning bids")
@@ -28,7 +28,7 @@ internal class DeviceContext private constructor(context: Context) {
28
28
 
29
29
  companion object {
30
30
  private const val TAG = "DeviceContext"
31
- internal const val SDK_VERSION = "0.11.0"
31
+ internal const val SDK_VERSION = "0.14.0"
32
32
 
33
33
  @Volatile
34
34
  private var instance: DeviceContext? = null
@@ -69,6 +69,69 @@ internal data class RevenueData(
69
69
  val currency: String
70
70
  )
71
71
 
72
+ // MARK: - Screen View Options
73
+
74
+ /**
75
+ * Options for customizing screen view tracking
76
+ *
77
+ * Allows app developers to provide page URL, content metadata,
78
+ * and custom dimensions for analytics events.
79
+ */
80
+ data class ScreenViewOptions(
81
+ /** Override the auto-generated page URL (must be a valid URL) */
82
+ val pageUrl: String? = null,
83
+
84
+ /** Content metadata for this screen/page */
85
+ val pageMeta: PageMetaData? = null,
86
+
87
+ /** Custom key-value dimensions attached to analytics events */
88
+ val customDimensions: Map<String, String>? = null
89
+ )
90
+
91
+ /**
92
+ * Content metadata for a screen/page view
93
+ *
94
+ * Matches the `page_meta_data` fields in the analytics schema.
95
+ */
96
+ @JsonClass(generateAdapter = true)
97
+ data class PageMetaData(
98
+ /** Canonical page URL */
99
+ @Json(name = "url")
100
+ val url: String? = null,
101
+
102
+ /** Content author */
103
+ @Json(name = "author")
104
+ val author: String? = null,
105
+
106
+ /** Page/screen title */
107
+ @Json(name = "title")
108
+ val title: String? = null,
109
+
110
+ /** Featured image URL */
111
+ @Json(name = "thumbnailUrl")
112
+ val thumbnailUrl: String? = null,
113
+
114
+ /** Content section/category */
115
+ @Json(name = "articleSection")
116
+ val articleSection: String? = null,
117
+
118
+ /** Comma-separated keywords */
119
+ @Json(name = "keywords")
120
+ val keywords: String? = null,
121
+
122
+ /** Content creation date (ISO 8601) */
123
+ @Json(name = "dateCreated")
124
+ val dateCreated: String? = null,
125
+
126
+ /** Content last modified date (ISO 8601) */
127
+ @Json(name = "dateModified")
128
+ val dateModified: String? = null,
129
+
130
+ /** Content publication date (ISO 8601) */
131
+ @Json(name = "datePublished")
132
+ val datePublished: String? = null
133
+ )
134
+
72
135
  // MARK: - Enhanced Analytics Events (matching web SDK data model)
73
136
 
74
137
  /**
@@ -178,7 +241,11 @@ internal data class PageViewEvent(
178
241
 
179
242
  // Custom dimensions
180
243
  @Json(name = "custom_dimensions")
181
- val customDimensions: Map<String, String> = emptyMap()
244
+ val customDimensions: Map<String, String> = emptyMap(),
245
+
246
+ // Page metadata (only for pageview events)
247
+ @Json(name = "page_meta_data")
248
+ val pageMetaData: PageMetaData? = null
182
249
  )
183
250
 
184
251
  /**
@@ -845,7 +912,19 @@ internal data class AuctionData(
845
912
  val bidPriceCpm: Double? = null,
846
913
 
847
914
  /** Creative ID from winning bid */
848
- val creativeId: String? = null
915
+ val creativeId: String? = null,
916
+
917
+ /** GAM price bucket (hb_pb value from targeting KVPs) */
918
+ val gamPriceBucket: String? = null,
919
+
920
+ /** Floor price sent to bidders */
921
+ val floorPrice: Double? = null,
922
+
923
+ /** Second-highest bid price (for computing min_bid_to_win) */
924
+ val secondHighestBid: Double? = null,
925
+
926
+ /** Demand channel ("S2S", "Google Ad Exchange", etc.) */
927
+ val demandChannel: String? = null
849
928
  ) {
850
929
  companion object {
851
930
  val EMPTY = AuctionData()
@@ -25,7 +25,10 @@ data class PlacementConfig(
25
25
  val sizes: List<AdSize>? = null,
26
26
 
27
27
  @Json(name = "refresh")
28
- val refresh: RefreshConfig? = null
28
+ val refresh: RefreshConfig? = null,
29
+
30
+ @Json(name = "floorPrice")
31
+ val floorPrice: Double? = null
29
32
  )
30
33
 
31
34
  /**
@@ -95,10 +95,52 @@ class BigCrunchAdsModule(reactContext: ReactApplicationContext) :
95
95
  * Track screen view
96
96
  */
97
97
  @ReactMethod
98
- fun trackScreenView(screenName: String, promise: Promise) {
98
+ fun trackScreenView(screenName: String, options: ReadableMap?, promise: Promise) {
99
99
  scope.launch {
100
100
  try {
101
- BigCrunchAds.trackScreenView(screenName)
101
+ var screenViewOptions: ScreenViewOptions? = null
102
+
103
+ if (options != null) {
104
+ var pageMeta: PageMetaData? = null
105
+ if (options.hasKey("pageMeta")) {
106
+ val metaMap = options.getMap("pageMeta")
107
+ if (metaMap != null) {
108
+ pageMeta = PageMetaData(
109
+ url = metaMap.getString("url"),
110
+ author = metaMap.getString("author"),
111
+ title = metaMap.getString("title"),
112
+ thumbnailUrl = metaMap.getString("thumbnailUrl"),
113
+ articleSection = metaMap.getString("articleSection"),
114
+ keywords = metaMap.getString("keywords"),
115
+ dateCreated = metaMap.getString("dateCreated"),
116
+ dateModified = metaMap.getString("dateModified"),
117
+ datePublished = metaMap.getString("datePublished")
118
+ )
119
+ }
120
+ }
121
+
122
+ var customDimensions: Map<String, String>? = null
123
+ if (options.hasKey("customDimensions")) {
124
+ val dimMap = options.getMap("customDimensions")
125
+ if (dimMap != null) {
126
+ val dims = mutableMapOf<String, String>()
127
+ val iterator = dimMap.keySetIterator()
128
+ while (iterator.hasNextKey()) {
129
+ val key = iterator.nextKey()
130
+ dimMap.getString(key)?.let { dims[key] = it }
131
+ }
132
+ customDimensions = dims
133
+ }
134
+ }
135
+
136
+ screenViewOptions = ScreenViewOptions(
137
+ pageUrl = if (options.hasKey("pageUrl")) options.getString("pageUrl") else null,
138
+ pageMeta = pageMeta,
139
+ customDimensions = customDimensions
140
+ )
141
+ }
142
+
143
+ BigCrunchAds.trackScreenView(screenName, screenViewOptions)
102
144
  promise.resolve(null)
103
145
  } catch (e: Exception) {
104
146
  promise.reject("TRACK_ERROR", "Failed to track screen view: ${e.message}", e)
@@ -501,6 +543,19 @@ class BigCrunchAdsModule(reactContext: ReactApplicationContext) :
501
543
  }
502
544
  }
503
545
 
546
+ /**
547
+ * Set account type
548
+ */
549
+ @ReactMethod
550
+ fun setAccountType(accountType: String, promise: Promise) {
551
+ try {
552
+ BigCrunchAds.setAccountType(accountType)
553
+ promise.resolve(null)
554
+ } catch (e: Exception) {
555
+ promise.reject("ACCOUNT_ERROR", "Failed to set account type: ${e.message}", e)
556
+ }
557
+ }
558
+
504
559
  /**
505
560
  * Set debug mode
506
561
  */
@@ -361,9 +361,15 @@ private class BannerDelegateWrapper: NSObject, GoogleMobileAds.BannerViewDelegat
361
361
  let responseInfo = bannerView.responseInfo
362
362
  let adMetadata = GoogleAdsAdapter.extractAdMetadata(from: responseInfo)
363
363
 
364
+ // Extract ad size from the loaded banner
365
+ let adSize = bannerView.adSize
366
+ let adSizeString = "\(Int(adSize.size.width))x\(Int(adSize.size.height))"
367
+
364
368
  analyticsClient.trackAdImpression(
365
369
  placementId: placementConfig.placementId,
366
370
  format: placementConfig.format,
371
+ gamAdUnit: placementConfig.gamAdUnit,
372
+ adSize: adSizeString,
367
373
  refreshCount: refreshCount,
368
374
  advertiserId: adMetadata["advertiser_id"] as? String,
369
375
  campaignId: adMetadata["campaign_id"] as? String,
@@ -426,6 +432,7 @@ private class InterstitialDelegateWrapper: NSObject, GoogleMobileAds.FullScreenC
426
432
  analyticsClient.trackAdImpression(
427
433
  placementId: placementConfig.placementId,
428
434
  format: placementConfig.format,
435
+ gamAdUnit: placementConfig.gamAdUnit,
429
436
  advertiserId: adMetadata["advertiser_id"] as? String,
430
437
  campaignId: adMetadata["campaign_id"] as? String,
431
438
  lineItemId: adMetadata["line_item_id"] as? String,
@@ -156,14 +156,16 @@ public final class BigCrunchAds {
156
156
  /**
157
157
  * Track screen view for analytics
158
158
  *
159
- * - Parameter screenName: Name of the screen being viewed
159
+ * - Parameters:
160
+ * - screenName: Name of the screen being viewed
161
+ * - options: Optional overrides for page URL, content metadata, and custom dimensions
160
162
  */
161
- public static func trackScreen(_ screenName: String) {
163
+ public static func trackScreen(_ screenName: String, options: ScreenViewOptions? = nil) {
162
164
  guard _isInitialized else {
163
165
  BCLogger.warning("trackScreen called before initialization, ignoring")
164
166
  return
165
167
  }
166
- analyticsClient.trackScreenView(screenName)
168
+ analyticsClient.trackScreenView(screenName, options: options)
167
169
  SessionManager.shared.startPageView()
168
170
  }
169
171
 
@@ -247,6 +249,25 @@ public final class BigCrunchAds {
247
249
  )
248
250
  }
249
251
 
252
+ // MARK: - Account Type
253
+
254
+ /**
255
+ * Set the account type for the current user
256
+ *
257
+ * Included in all analytics events. Defaults to "guest" if not set.
258
+ * Valid values: "guest", "logged_in", "paid", "subscriber", "free"
259
+ *
260
+ * - Parameter accountType: The user's account type
261
+ */
262
+ public static func setAccountType(_ accountType: String) {
263
+ guard _isInitialized else {
264
+ BCLogger.warning("setAccountType called before initialization, ignoring")
265
+ return
266
+ }
267
+ analyticsClient.setAccountType(accountType)
268
+ BCLogger.debug("Account type set to: \(accountType)")
269
+ }
270
+
250
271
  // MARK: - Privacy Compliance
251
272
 
252
273
  /**
@@ -352,10 +373,12 @@ public final class BigCrunchAds {
352
373
  /**
353
374
  * Track screen view for analytics (alias for Android API compatibility)
354
375
  *
355
- * - Parameter screenName: Name of the screen being viewed
376
+ * - Parameters:
377
+ * - screenName: Name of the screen being viewed
378
+ * - options: Optional overrides for page URL, content metadata, and custom dimensions
356
379
  */
357
- public static func trackScreenView(_ screenName: String) {
358
- trackScreen(screenName)
380
+ public static func trackScreenView(_ screenName: String, options: ScreenViewOptions? = nil) {
381
+ trackScreen(screenName, options: options)
359
382
  }
360
383
 
361
384
  // MARK: - Internal
@@ -172,8 +172,8 @@ public final class BigCrunchRewarded {
172
172
 
173
173
  // Fetch S2S demand
174
174
  if let bidRequestClient = BigCrunchAds.getBidRequestClient() {
175
- let targeting = await bidRequestClient.fetchDemand(placement: placement)
176
- if let targeting = targeting, !targeting.isEmpty {
175
+ let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
176
+ if let targeting = bidResponse?.targeting, !targeting.isEmpty {
177
177
  var customTargeting = request.customTargeting ?? [:]
178
178
  for (key, value) in targeting {
179
179
  customTargeting[key] = value
@@ -183,6 +183,14 @@ public final class BigCrunchRewarded {
183
183
  } else {
184
184
  BCLogger.warning("\(TAG): S2S demand fetch returned no targeting, continuing with Google only")
185
185
  }
186
+
187
+ // Store auction data for analytics
188
+ let auctionData = bidResponse?.auctionData ?? AuctionData(
189
+ auctionId: nil, bidder: "google_ad_exchange", bidPriceCpm: nil, creativeId: nil,
190
+ gamPriceBucket: nil, floorPrice: placement.floorPrice, secondHighestBid: nil,
191
+ demandChannel: "Google Ad Exchange"
192
+ )
193
+ BigCrunchAds.getAnalyticsClient().setAuctionData(placementId: placement.placementId, auctionData: auctionData)
186
194
  }
187
195
 
188
196
  // Load Google rewarded ad
@@ -160,8 +160,8 @@ internal class AdOrchestrator {
160
160
  let adRequest = GoogleMobileAds.Request()
161
161
 
162
162
  // 3. Fetch S2S demand (even if it fails, continue with Google)
163
- let targeting = await bidRequestClient.fetchDemand(placement: placement)
164
- if let targeting = targeting, !targeting.isEmpty {
163
+ let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
164
+ if let targeting = bidResponse?.targeting, !targeting.isEmpty {
165
165
  var customTargeting = adRequest.customTargeting ?? [:]
166
166
  for (key, value) in targeting {
167
167
  customTargeting[key] = value
@@ -172,6 +172,14 @@ internal class AdOrchestrator {
172
172
  BCLogger.warning("\(AdOrchestrator.TAG): S2S demand fetch returned no targeting, continuing with Google only")
173
173
  }
174
174
 
175
+ // Store auction data for analytics (or default to GAM if no S2S)
176
+ let auctionData = bidResponse?.auctionData ?? AuctionData(
177
+ auctionId: nil, bidder: "google_ad_exchange", bidPriceCpm: nil, creativeId: nil,
178
+ gamPriceBucket: nil, floorPrice: placement.floorPrice, secondHighestBid: nil,
179
+ demandChannel: "Google Ad Exchange"
180
+ )
181
+ analyticsClient.setAuctionData(placementId: placement.placementId, auctionData: auctionData)
182
+
175
183
  // 4. Load Google ad with the (possibly enriched) ad request
176
184
  let delegateWrapper = BannerDelegateAdapterWrapper(
177
185
  placementId: placement.placementId,
@@ -284,8 +292,8 @@ internal class AdOrchestrator {
284
292
  let adRequest = GoogleMobileAds.Request()
285
293
 
286
294
  // 3. Fetch S2S demand
287
- let targeting = await bidRequestClient.fetchDemand(placement: placement)
288
- if let targeting = targeting, !targeting.isEmpty {
295
+ let bidResponse = await bidRequestClient.fetchDemand(placement: placement)
296
+ if let targeting = bidResponse?.targeting, !targeting.isEmpty {
289
297
  var customTargeting = adRequest.customTargeting ?? [:]
290
298
  for (key, value) in targeting {
291
299
  customTargeting[key] = value
@@ -296,6 +304,14 @@ internal class AdOrchestrator {
296
304
  BCLogger.warning("\(AdOrchestrator.TAG): S2S demand fetch returned no targeting, continuing with Google only")
297
305
  }
298
306
 
307
+ // Store auction data for analytics (or default to GAM if no S2S)
308
+ let auctionData = bidResponse?.auctionData ?? AuctionData(
309
+ auctionId: nil, bidder: "google_ad_exchange", bidPriceCpm: nil, creativeId: nil,
310
+ gamPriceBucket: nil, floorPrice: placement.floorPrice, secondHighestBid: nil,
311
+ demandChannel: "Google Ad Exchange"
312
+ )
313
+ analyticsClient.setAuctionData(placementId: placement.placementId, auctionData: auctionData)
314
+
299
315
  // Check if we should use test ad units
300
316
  let useTestAds = configManager.shouldUseTestAds()
301
317