@betarena/ad-engine 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betarena/ad-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Betarena ad-engine widget",
6
6
  "keywords": [
@@ -141,7 +141,36 @@
141
141
  * @description
142
142
  * 📝 `Map` where, `key=CreativeId` and `value=AdvertObject` (temporary)
143
143
  */
144
- mapCreative = new Map < number, AdsCreativeMain > ()
144
+ mapCreative = new Map < number, AdsCreativeMain > (),
145
+ /**
146
+ * @description
147
+ * 📝 Shared promise for one-time prerequisite initialization (device type + geolocation).
148
+ * Set on first call to `ensureReady()`; subsequent callers await the same promise.
149
+ */
150
+ readinessPromise: Promise < void > | null = null,
151
+ /**
152
+ * @description
153
+ * 📝 ID of the deferred `initialize()` timeout, used to cancel it if the component
154
+ * is destroyed before the delay elapses.
155
+ */
156
+ initTimeoutId: ReturnType < typeof setTimeout > | null = null,
157
+ /**
158
+ * @description
159
+ * 📝 Set to `true` once `onDestroy` fires, so any in-flight async work can bail out.
160
+ */
161
+ isDestroyed = false,
162
+ /**
163
+ * @description
164
+ * 📝 Incremented on each `refreshAds()` call. Passed into `injectBetarenaAds()`
165
+ * so that a superseded refresh can bail out after its network awaits complete.
166
+ */
167
+ refreshSerial = 0,
168
+ /**
169
+ * @description
170
+ * 📝 Previous value of `window.betarenaAdEngine` saved before this instance assigns it,
171
+ * so it can be restored on destroy instead of unconditionally deleting.
172
+ */
173
+ prevWindowApi: unknown = undefined
145
174
  ;
146
175
 
147
176
  const
@@ -168,7 +197,7 @@
168
197
 
169
198
  /**
170
199
  * @author
171
- * @migbash
200
+ * @jonsnowpt
172
201
  * @summary
173
202
  * 🟥 MAIN
174
203
  * @description
@@ -177,16 +206,44 @@
177
206
  */
178
207
  function generateElementMap
179
208
  (
209
+ rootElement?: ParentNode
180
210
  ): void
181
211
  {
212
+ mapBetarenaAdvertStandardElement.clear();
213
+
182
214
  const
183
215
  /**
184
216
  * @description
185
217
  * 📝 `List` of `HTMLElements` identified on `page` expecting a target `zone` advertisement injection.
186
218
  */
187
- listElementTarget = document.querySelectorAll('[data-betarena-zone-id]')
219
+ listElementTarget = (rootElement ?? document).querySelectorAll('[data-betarena-zone-id]')
188
220
  ;
189
221
 
222
+ // ╭─────
223
+ // │ NOTE:
224
+ // │ |: querySelectorAll only returns descendants — if rootElement itself carries
225
+ // │ |: the zone attribute it would be missed. Include it explicitly.
226
+ // ╰─────
227
+ if (rootElement instanceof Element && rootElement.hasAttribute('data-betarena-zone-id'))
228
+ {
229
+ const
230
+ value = rootElement.getAttribute('data-betarena-zone-id')
231
+ ;
232
+
233
+ if (value)
234
+ {
235
+ for (const rawToken of value.split(','))
236
+ {
237
+ const
238
+ token = rawToken.trim(),
239
+ id = Number.parseInt(token, 10)
240
+ ;
241
+ if (!token || !Number.isFinite(id)) continue;
242
+ mapBetarenaAdvertStandardElement.set(id, rootElement);
243
+ }
244
+ }
245
+ }
246
+
190
247
  // [🐞]
191
248
  logger
192
249
  (
@@ -219,23 +276,14 @@
219
276
  ]
220
277
  );
221
278
 
222
- if (value.includes(','))
279
+ for (const rawToken of value.split(','))
223
280
  {
224
281
  const
225
- /**
226
- * @description
227
- * 📝 Split value by `,`
228
- */
229
- values = value.split(',')
282
+ token = rawToken.trim(),
283
+ id = Number.parseInt(token, 10)
230
284
  ;
231
-
232
- for (const val of values)
233
- mapBetarenaAdvertStandardElement.set(parseInt(val), elem);
234
- ;
235
- }
236
- else
237
- {
238
- mapBetarenaAdvertStandardElement.set(parseInt(value), elem);
285
+ if (!token || !Number.isFinite(id)) continue;
286
+ mapBetarenaAdvertStandardElement.set(id, elem);
239
287
  }
240
288
  }
241
289
 
@@ -254,7 +302,7 @@
254
302
 
255
303
  /**
256
304
  * @author
257
- * @migbash
305
+ * @jonsnowpt
258
306
  * @summary
259
307
  * 🟦 HELPER
260
308
  * @description
@@ -265,7 +313,9 @@
265
313
  */
266
314
  async function injectBetarenaAds
267
315
  (
268
- opts: IAdsServiceData['request']['body']
316
+ opts: IAdsServiceData['request']['body'],
317
+ includesGlobalZone: boolean,
318
+ refreshToken: number
269
319
  ): Promise < void >
270
320
  {
271
321
  if (!document) return;
@@ -315,6 +365,8 @@
315
365
  ]
316
366
  );
317
367
 
368
+ if (isDestroyed || refreshToken !== refreshSerial) return;
369
+
318
370
  storeSession.updateData
319
371
  (
320
372
  [
@@ -363,7 +415,17 @@
363
415
  // ╰─────
364
416
  for (const [zoneId, element] of mapBetarenaAdvertStandardElement)
365
417
  {
366
- if (!geoLocation) continue;
418
+ const
419
+ el = element as HTMLElement,
420
+ parent = el.parentElement as HTMLElement | null,
421
+ mountedAttr =
422
+ [
423
+ el.dataset.betarenaAdMounted,
424
+ parent?.dataset?.betarenaAdMounted
425
+ ].filter(Boolean).join(','),
426
+ mountedZones = mountedAttr.split(',').map(z => z.trim()).filter(Boolean)
427
+ ;
428
+ if (mountedZones.includes(String(zoneId))) continue;
367
429
 
368
430
  const
369
431
  /**
@@ -443,6 +505,11 @@
443
505
  }
444
506
  );
445
507
  ;
508
+ if (creativeAdData.length > 0)
509
+ {
510
+ mountedZones.push(String(zoneId));
511
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
512
+ }
446
513
  }
447
514
  else if (zoneId == 2 || zoneId == 3)
448
515
  {
@@ -459,9 +526,16 @@
459
526
  }
460
527
  );
461
528
  ;
529
+ if (creativeAdData.length > 0)
530
+ {
531
+ mountedZones.push(String(zoneId));
532
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
533
+ }
462
534
  }
463
535
  else if (zoneId == 4)
464
536
  {
537
+ if (!element.parentElement) continue;
538
+
465
539
  for (const adData of (creativeAdData ?? []))
466
540
  new AdvertLeftSide
467
541
  (
@@ -474,6 +548,29 @@
474
548
  }
475
549
  );
476
550
  ;
551
+ if (creativeAdData.length > 0)
552
+ {
553
+ mountedZones.push(String(zoneId));
554
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
555
+ // ╭─────
556
+ // │ NOTE:
557
+ // │ |: Also mark the actual injection target (parentElement) so that
558
+ // │ |: if the zone element is replaced while the parent persists,
559
+ // │ |: subsequent refreshAds() calls won't re-inject into the same parent.
560
+ // ╰─────
561
+ if (element.parentElement)
562
+ {
563
+ const
564
+ parentMountedRaw = (element.parentElement as HTMLElement).dataset.betarenaAdMounted ?? '',
565
+ parentMountedZones = parentMountedRaw.split(',').map(z => z.trim()).filter(Boolean)
566
+ ;
567
+ if (!parentMountedZones.includes(String(zoneId)))
568
+ {
569
+ parentMountedZones.push(String(zoneId));
570
+ (element.parentElement as HTMLElement).dataset.betarenaAdMounted = parentMountedZones.join(',');
571
+ }
572
+ }
573
+ }
477
574
  }
478
575
  }
479
576
 
@@ -482,8 +579,11 @@
482
579
  // │ |: for case of injection of 'GLOBAL' placements for adverts,
483
580
  // │ |: ⦿ (a.k.a SLIDER / POPUP ads)
484
581
  // │ |: ⦿ (a.k.a document.body injections)
582
+ // │ |: `includesGlobalZone` is computed from the caller's original scope in refreshAds()
583
+ // │ |: (not from DOM-filtered targetZoneIds) so global placements inject correctly
584
+ // │ |: even when no zone-1 element exists in the DOM.
485
585
  // ╰─────
486
- if (authorId || authorArticleTagIds.length > 0)
586
+ if (includesGlobalZone && (authorId || authorArticleTagIds.length > 0))
487
587
  {
488
588
  // ╭─────
489
589
  // │ NOTE:
@@ -658,8 +758,9 @@
658
758
  // │ |: BUT, data for ADS was still successfully fetched
659
759
  // │ |: ⦿ (a.k.a Advert Condition was RETRIEVED/HIT).
660
760
  // │ |: Inject STANDARD SLIDER adverts in 'document.body'
761
+ // │ |: Guard with `includesGlobalZone` to avoid body injections on scoped refreshes.
661
762
  // ╰─────
662
- else
763
+ else if (includesGlobalZone)
663
764
  {
664
765
  // [🐞]
665
766
  logger
@@ -699,7 +800,129 @@
699
800
 
700
801
  /**
701
802
  * @author
702
- * @migbash
803
+ * @jonsnowpt
804
+ * @summary
805
+ * 🟦 HELPER
806
+ * @description
807
+ * 📝 Ensures prerequisites (`deviceType`, `geoLocation`) are resolved exactly once.
808
+ * Callers that race — whether `initialize()` or an external `refreshAds()` — all
809
+ * await the same in-flight promise.
810
+ * @returns { Promise < void > }
811
+ */
812
+ async function ensureReady
813
+ (
814
+ ): Promise < void >
815
+ {
816
+ if (readinessPromise) return readinessPromise;
817
+
818
+ readinessPromise =
819
+ (
820
+ async () =>
821
+ {
822
+ try
823
+ {
824
+ deviceType = detectDeviceWithUA() as IDeviceType;
825
+ geoLocation = await getUserLocation();
826
+ }
827
+ catch (e)
828
+ {
829
+ readinessPromise = null;
830
+ throw e;
831
+ }
832
+ }
833
+ )();
834
+
835
+ return readinessPromise;
836
+ }
837
+
838
+ /**
839
+ * @author
840
+ * @jonsnowpt
841
+ * @summary
842
+ * 🟥 MAIN
843
+ * @description
844
+ * 📝 Re-scans the DOM for ad zone elements and injects ads for any that have not
845
+ * yet been mounted. Safe to call multiple times — already-injected nodes store a
846
+ * comma-separated list of mounted zone IDs in `data-betarena-ad-mounted`
847
+ * (e.g. `"1,2"`), and zones whose ID is present in that list are skipped.
848
+ * @param {{ zoneIds?: number[]; rootElement?: HTMLElement }} [opts]
849
+ * 💠 Optional scope: restrict to specific zone IDs and / or a DOM subtree.
850
+ * @returns { Promise < void > }
851
+ */
852
+ async function refreshAds
853
+ (
854
+ opts?:
855
+ {
856
+ zoneIds?: number[];
857
+ rootElement?: HTMLElement;
858
+ }
859
+ ): Promise < void >
860
+ {
861
+ if (isDestroyed) return;
862
+
863
+ const
864
+ mySerial = ++refreshSerial
865
+ ;
866
+
867
+ await ensureReady();
868
+
869
+ if (isDestroyed || mySerial !== refreshSerial) return;
870
+
871
+ generateElementMap(opts?.rootElement);
872
+
873
+ let
874
+ /**
875
+ * @description
876
+ * 📝 Zone IDs discovered in the current DOM scan (or filtered subset).
877
+ */
878
+ targetZoneIds = [...mapBetarenaAdvertStandardElement.keys()]
879
+ ;
880
+
881
+ if (opts?.zoneIds && opts.zoneIds.length > 0)
882
+ targetZoneIds = targetZoneIds.filter(id => opts.zoneIds!.includes(id));
883
+ ;
884
+
885
+ // ╭─────
886
+ // │ NOTE:
887
+ // │ |: Only tear down previously injected global/body components when the
888
+ // │ |: refresh scope includes zone 1 (the global placement zone) or when
889
+ // │ |: no zone filter was applied (full refresh).
890
+ // │ |: Scoped refreshes that exclude zone 1 leave existing body-mounted
891
+ // │ |: widgets untouched to avoid permanently removing them.
892
+ // ╰─────
893
+ if (!opts?.zoneIds || opts.zoneIds.length === 0 || opts.zoneIds.includes(1))
894
+ {
895
+ for (const item of listAdWidgetElements)
896
+ item.$destroy();
897
+ ;
898
+ listAdWidgetElements.length = 0;
899
+ }
900
+
901
+ await injectBetarenaAds
902
+ (
903
+ {
904
+ deviceType,
905
+ isoCountryCode: geoLocation?.country_code ?? 'EN',
906
+ authorId,
907
+ tagIds: authorArticleTagIds,
908
+ zoneIds: targetZoneIds
909
+ },
910
+ // ╭─────
911
+ // │ NOTE:
912
+ // │ |: Derived from the caller's originally requested scope, NOT targetZoneIds
913
+ // │ |: (which is DOM-filtered). If the DOM has no zone-1 element, targetZoneIds
914
+ // │ |: won't include 1, but a full/zone-1 refresh should still inject global placements.
915
+ // ╰─────
916
+ !opts?.zoneIds || opts.zoneIds.length === 0 || opts.zoneIds.includes(1),
917
+ mySerial
918
+ );
919
+
920
+ return;
921
+ }
922
+
923
+ /**
924
+ * @author
925
+ * @jonsnowpt
703
926
  * @summary
704
927
  * 🟥 MAIN
705
928
  * @description
@@ -710,10 +933,6 @@
710
933
  (
711
934
  ): Promise < void >
712
935
  {
713
- deviceType = detectDeviceWithUA() as IDeviceType;
714
- geoLocation = await getUserLocation();
715
- generateElementMap();
716
-
717
936
  // [🐞]
718
937
  logger
719
938
  (
@@ -722,18 +941,7 @@
722
941
  ]
723
942
  );
724
943
 
725
- injectBetarenaAds
726
- (
727
- {
728
- deviceType,
729
- // deviceType: 'desktop',
730
- isoCountryCode: geoLocation.country_code ?? 'EN',
731
- // isoCountryCode: 'BR',
732
- authorId,
733
- tagIds: authorArticleTagIds,
734
- zoneIds: [...mapBetarenaAdvertStandardElement.keys()]
735
- }
736
- );
944
+ await refreshAds();
737
945
 
738
946
  return;
739
947
  }
@@ -756,9 +964,37 @@
756
964
  betarenaAdEngineStore.useLocalStorage();
757
965
  // ╭─────
758
966
  // │ NOTE:
967
+ // │ |: Expose public API immediately so host apps can call refreshAds()
968
+ // │ |: without waiting for the delayed initialize().
969
+ // │ |: refreshAds() self-initializes prerequisites via ensureReady() if needed.
970
+ // │ |: Save any existing value so it can be restored when this instance is destroyed.
971
+ // ╰─────
972
+ prevWindowApi = (window as any).betarenaAdEngine;
973
+ (window as any).betarenaAdEngine = { refreshAds };
974
+ // ╭─────
975
+ // │ NOTE:
759
976
  // │ |: Delay initialization to ensure all elements are loaded on page
760
977
  // ╰─────
761
- setTimeout(() => { initialize(); return; }, 1000);
978
+ initTimeoutId = setTimeout
979
+ (
980
+ () =>
981
+ {
982
+ initialize().catch
983
+ (
984
+ (error) =>
985
+ {
986
+ logger
987
+ (
988
+ [
989
+ '🚨 checkpoint ➤ initialize(..) // UNHANDLED ERROR',
990
+ `🔹 [var] ➤ error ${error}`
991
+ ]
992
+ );
993
+ }
994
+ );
995
+ },
996
+ 1000
997
+ );
762
998
  return;
763
999
  }
764
1000
  );
@@ -776,6 +1012,25 @@
776
1012
  ],
777
1013
  );
778
1014
 
1015
+ isDestroyed = true;
1016
+
1017
+ if (initTimeoutId !== null)
1018
+ clearTimeout(initTimeoutId);
1019
+ ;
1020
+
1021
+ // ╭─────
1022
+ // │ NOTE:
1023
+ // │ |: Only remove/restore the global if this instance still owns it.
1024
+ // │ |: A later-mounted instance or host code may have overwritten it.
1025
+ // ╰─────
1026
+ if ((window as any).betarenaAdEngine?.refreshAds === refreshAds)
1027
+ {
1028
+ if (prevWindowApi !== undefined)
1029
+ (window as any).betarenaAdEngine = prevWindowApi;
1030
+ else
1031
+ delete (window as any).betarenaAdEngine;
1032
+ }
1033
+
779
1034
  // ╭─────
780
1035
  // │ NOTE:
781
1036
  // │ |: Destroy all dynamically created advert components