@betarena/ad-engine 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
4
4
  "private": false,
5
5
  "description": "Betarena ad-engine widget",
6
6
  "keywords": [
@@ -109,6 +109,10 @@
109
109
  isStandalone = true
110
110
  ;
111
111
 
112
+ // Retained as a supported public prop for API compatibility.
113
+ // Device detection is UA-based; this value is not consumed internally.
114
+ $: void deviceWidthList;
115
+
112
116
  /**
113
117
  * @description
114
118
  * 📝 Component Local Interface
@@ -141,7 +145,36 @@
141
145
  * @description
142
146
  * 📝 `Map` where, `key=CreativeId` and `value=AdvertObject` (temporary)
143
147
  */
144
- mapCreative = new Map < number, AdsCreativeMain > ()
148
+ mapCreative = new Map < number, AdsCreativeMain > (),
149
+ /**
150
+ * @description
151
+ * 📝 Shared promise for one-time prerequisite initialization (device type + geolocation).
152
+ * Set on first call to `ensureReady()`; subsequent callers await the same promise.
153
+ */
154
+ readinessPromise: Promise < void > | null = null,
155
+ /**
156
+ * @description
157
+ * 📝 ID of the deferred `initialize()` timeout, used to cancel it if the component
158
+ * is destroyed before the delay elapses.
159
+ */
160
+ initTimeoutId: ReturnType < typeof setTimeout > | null = null,
161
+ /**
162
+ * @description
163
+ * 📝 Set to `true` once `onDestroy` fires, so any in-flight async work can bail out.
164
+ */
165
+ isDestroyed = false,
166
+ /**
167
+ * @description
168
+ * 📝 Incremented on each `refreshAds()` call. Passed into `injectBetarenaAds()`
169
+ * so that a superseded refresh can bail out after its network awaits complete.
170
+ */
171
+ refreshSerial = 0,
172
+ /**
173
+ * @description
174
+ * 📝 Previous value of `window.betarenaAdEngine` saved before this instance assigns it,
175
+ * so it can be restored on destroy instead of unconditionally deleting.
176
+ */
177
+ prevWindowApi: unknown = undefined
145
178
  ;
146
179
 
147
180
  const
@@ -168,7 +201,7 @@
168
201
 
169
202
  /**
170
203
  * @author
171
- * @migbash
204
+ * @jonsnowpt
172
205
  * @summary
173
206
  * 🟥 MAIN
174
207
  * @description
@@ -177,16 +210,44 @@
177
210
  */
178
211
  function generateElementMap
179
212
  (
213
+ rootElement?: ParentNode
180
214
  ): void
181
215
  {
216
+ mapBetarenaAdvertStandardElement.clear();
217
+
182
218
  const
183
219
  /**
184
220
  * @description
185
221
  * 📝 `List` of `HTMLElements` identified on `page` expecting a target `zone` advertisement injection.
186
222
  */
187
- listElementTarget = document.querySelectorAll('[data-betarena-zone-id]')
223
+ listElementTarget = (rootElement ?? document).querySelectorAll('[data-betarena-zone-id]')
188
224
  ;
189
225
 
226
+ // ╭─────
227
+ // │ NOTE:
228
+ // │ |: querySelectorAll only returns descendants — if rootElement itself carries
229
+ // │ |: the zone attribute it would be missed. Include it explicitly.
230
+ // ╰─────
231
+ if (rootElement instanceof Element && rootElement.hasAttribute('data-betarena-zone-id'))
232
+ {
233
+ const
234
+ value = rootElement.getAttribute('data-betarena-zone-id')
235
+ ;
236
+
237
+ if (value)
238
+ {
239
+ for (const rawToken of value.split(','))
240
+ {
241
+ const
242
+ token = rawToken.trim(),
243
+ id = Number.parseInt(token, 10)
244
+ ;
245
+ if (!token || !Number.isFinite(id)) continue;
246
+ mapBetarenaAdvertStandardElement.set(id, rootElement);
247
+ }
248
+ }
249
+ }
250
+
190
251
  // [🐞]
191
252
  logger
192
253
  (
@@ -219,23 +280,14 @@
219
280
  ]
220
281
  );
221
282
 
222
- if (value.includes(','))
283
+ for (const rawToken of value.split(','))
223
284
  {
224
285
  const
225
- /**
226
- * @description
227
- * 📝 Split value by `,`
228
- */
229
- values = value.split(',')
286
+ token = rawToken.trim(),
287
+ id = Number.parseInt(token, 10)
230
288
  ;
231
-
232
- for (const val of values)
233
- mapBetarenaAdvertStandardElement.set(parseInt(val), elem);
234
- ;
235
- }
236
- else
237
- {
238
- mapBetarenaAdvertStandardElement.set(parseInt(value), elem);
289
+ if (!token || !Number.isFinite(id)) continue;
290
+ mapBetarenaAdvertStandardElement.set(id, elem);
239
291
  }
240
292
  }
241
293
 
@@ -254,7 +306,7 @@
254
306
 
255
307
  /**
256
308
  * @author
257
- * @migbash
309
+ * @jonsnowpt
258
310
  * @summary
259
311
  * 🟦 HELPER
260
312
  * @description
@@ -265,7 +317,9 @@
265
317
  */
266
318
  async function injectBetarenaAds
267
319
  (
268
- opts: IAdsServiceData['request']['body']
320
+ opts: IAdsServiceData['request']['body'],
321
+ includesGlobalZone: boolean,
322
+ refreshToken: number
269
323
  ): Promise < void >
270
324
  {
271
325
  if (!document) return;
@@ -315,6 +369,8 @@
315
369
  ]
316
370
  );
317
371
 
372
+ if (isDestroyed || refreshToken !== refreshSerial) return;
373
+
318
374
  storeSession.updateData
319
375
  (
320
376
  [
@@ -363,7 +419,17 @@
363
419
  // ╰─────
364
420
  for (const [zoneId, element] of mapBetarenaAdvertStandardElement)
365
421
  {
366
- if (!geoLocation) continue;
422
+ const
423
+ el = element as HTMLElement,
424
+ parent = el.parentElement as HTMLElement | null,
425
+ mountedAttr =
426
+ [
427
+ el.dataset.betarenaAdMounted,
428
+ parent?.dataset?.betarenaAdMounted
429
+ ].filter(Boolean).join(','),
430
+ mountedZones = mountedAttr.split(',').map(z => z.trim()).filter(Boolean)
431
+ ;
432
+ if (mountedZones.includes(String(zoneId))) continue;
367
433
 
368
434
  const
369
435
  /**
@@ -443,6 +509,11 @@
443
509
  }
444
510
  );
445
511
  ;
512
+ if (creativeAdData.length > 0)
513
+ {
514
+ mountedZones.push(String(zoneId));
515
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
516
+ }
446
517
  }
447
518
  else if (zoneId == 2 || zoneId == 3)
448
519
  {
@@ -459,9 +530,16 @@
459
530
  }
460
531
  );
461
532
  ;
533
+ if (creativeAdData.length > 0)
534
+ {
535
+ mountedZones.push(String(zoneId));
536
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
537
+ }
462
538
  }
463
539
  else if (zoneId == 4)
464
540
  {
541
+ if (!element.parentElement) continue;
542
+
465
543
  for (const adData of (creativeAdData ?? []))
466
544
  new AdvertLeftSide
467
545
  (
@@ -474,6 +552,29 @@
474
552
  }
475
553
  );
476
554
  ;
555
+ if (creativeAdData.length > 0)
556
+ {
557
+ mountedZones.push(String(zoneId));
558
+ el.dataset.betarenaAdMounted = mountedZones.join(',');
559
+ // ╭─────
560
+ // │ NOTE:
561
+ // │ |: Also mark the actual injection target (parentElement) so that
562
+ // │ |: if the zone element is replaced while the parent persists,
563
+ // │ |: subsequent refreshAds() calls won't re-inject into the same parent.
564
+ // ╰─────
565
+ if (element.parentElement)
566
+ {
567
+ const
568
+ parentMountedRaw = (element.parentElement as HTMLElement).dataset.betarenaAdMounted ?? '',
569
+ parentMountedZones = parentMountedRaw.split(',').map(z => z.trim()).filter(Boolean)
570
+ ;
571
+ if (!parentMountedZones.includes(String(zoneId)))
572
+ {
573
+ parentMountedZones.push(String(zoneId));
574
+ (element.parentElement as HTMLElement).dataset.betarenaAdMounted = parentMountedZones.join(',');
575
+ }
576
+ }
577
+ }
477
578
  }
478
579
  }
479
580
 
@@ -482,8 +583,11 @@
482
583
  // │ |: for case of injection of 'GLOBAL' placements for adverts,
483
584
  // │ |: ⦿ (a.k.a SLIDER / POPUP ads)
484
585
  // │ |: ⦿ (a.k.a document.body injections)
586
+ // │ |: `includesGlobalZone` is computed from the caller's original scope in refreshAds()
587
+ // │ |: (not from DOM-filtered targetZoneIds) so global placements inject correctly
588
+ // │ |: even when no zone-1 element exists in the DOM.
485
589
  // ╰─────
486
- if (authorId || authorArticleTagIds.length > 0)
590
+ if (includesGlobalZone && (authorId || authorArticleTagIds.length > 0))
487
591
  {
488
592
  // ╭─────
489
593
  // │ NOTE:
@@ -658,8 +762,9 @@
658
762
  // │ |: BUT, data for ADS was still successfully fetched
659
763
  // │ |: ⦿ (a.k.a Advert Condition was RETRIEVED/HIT).
660
764
  // │ |: Inject STANDARD SLIDER adverts in 'document.body'
765
+ // │ |: Guard with `includesGlobalZone` to avoid body injections on scoped refreshes.
661
766
  // ╰─────
662
- else
767
+ else if (includesGlobalZone)
663
768
  {
664
769
  // [🐞]
665
770
  logger
@@ -699,7 +804,129 @@
699
804
 
700
805
  /**
701
806
  * @author
702
- * @migbash
807
+ * @jonsnowpt
808
+ * @summary
809
+ * 🟦 HELPER
810
+ * @description
811
+ * 📝 Ensures prerequisites (`deviceType`, `geoLocation`) are resolved exactly once.
812
+ * Callers that race — whether `initialize()` or an external `refreshAds()` — all
813
+ * await the same in-flight promise.
814
+ * @returns { Promise < void > }
815
+ */
816
+ async function ensureReady
817
+ (
818
+ ): Promise < void >
819
+ {
820
+ if (readinessPromise) return readinessPromise;
821
+
822
+ readinessPromise =
823
+ (
824
+ async () =>
825
+ {
826
+ try
827
+ {
828
+ deviceType = detectDeviceWithUA() as IDeviceType;
829
+ geoLocation = await getUserLocation();
830
+ }
831
+ catch (e)
832
+ {
833
+ readinessPromise = null;
834
+ throw e;
835
+ }
836
+ }
837
+ )();
838
+
839
+ return readinessPromise;
840
+ }
841
+
842
+ /**
843
+ * @author
844
+ * @jonsnowpt
845
+ * @summary
846
+ * 🟥 MAIN
847
+ * @description
848
+ * 📝 Re-scans the DOM for ad zone elements and injects ads for any that have not
849
+ * yet been mounted. Safe to call multiple times — already-injected nodes store a
850
+ * comma-separated list of mounted zone IDs in `data-betarena-ad-mounted`
851
+ * (e.g. `"1,2"`), and zones whose ID is present in that list are skipped.
852
+ * @param {{ zoneIds?: number[]; rootElement?: HTMLElement }} [opts]
853
+ * 💠 Optional scope: restrict to specific zone IDs and / or a DOM subtree.
854
+ * @returns { Promise < void > }
855
+ */
856
+ async function refreshAds
857
+ (
858
+ opts?:
859
+ {
860
+ zoneIds?: number[];
861
+ rootElement?: HTMLElement;
862
+ }
863
+ ): Promise < void >
864
+ {
865
+ if (isDestroyed) return;
866
+
867
+ const
868
+ mySerial = ++refreshSerial
869
+ ;
870
+
871
+ await ensureReady();
872
+
873
+ if (isDestroyed || mySerial !== refreshSerial) return;
874
+
875
+ generateElementMap(opts?.rootElement);
876
+
877
+ let
878
+ /**
879
+ * @description
880
+ * 📝 Zone IDs discovered in the current DOM scan (or filtered subset).
881
+ */
882
+ targetZoneIds = [...mapBetarenaAdvertStandardElement.keys()]
883
+ ;
884
+
885
+ if (opts?.zoneIds && opts.zoneIds.length > 0)
886
+ targetZoneIds = targetZoneIds.filter(id => opts.zoneIds!.includes(id));
887
+ ;
888
+
889
+ // ╭─────
890
+ // │ NOTE:
891
+ // │ |: Only tear down previously injected global/body components when the
892
+ // │ |: refresh scope includes zone 1 (the global placement zone) or when
893
+ // │ |: no zone filter was applied (full refresh).
894
+ // │ |: Scoped refreshes that exclude zone 1 leave existing body-mounted
895
+ // │ |: widgets untouched to avoid permanently removing them.
896
+ // ╰─────
897
+ if (!opts?.zoneIds || opts.zoneIds.length === 0 || opts.zoneIds.includes(1))
898
+ {
899
+ for (const item of listAdWidgetElements)
900
+ item.$destroy();
901
+ ;
902
+ listAdWidgetElements.length = 0;
903
+ }
904
+
905
+ await injectBetarenaAds
906
+ (
907
+ {
908
+ deviceType,
909
+ isoCountryCode: geoLocation?.country_code ?? 'EN',
910
+ authorId: authorId ?? undefined,
911
+ tagIds: authorArticleTagIds,
912
+ zoneIds: targetZoneIds
913
+ },
914
+ // ╭─────
915
+ // │ NOTE:
916
+ // │ |: Derived from the caller's originally requested scope, NOT targetZoneIds
917
+ // │ |: (which is DOM-filtered). If the DOM has no zone-1 element, targetZoneIds
918
+ // │ |: won't include 1, but a full/zone-1 refresh should still inject global placements.
919
+ // ╰─────
920
+ !opts?.zoneIds || opts.zoneIds.length === 0 || opts.zoneIds.includes(1),
921
+ mySerial
922
+ );
923
+
924
+ return;
925
+ }
926
+
927
+ /**
928
+ * @author
929
+ * @jonsnowpt
703
930
  * @summary
704
931
  * 🟥 MAIN
705
932
  * @description
@@ -710,10 +937,6 @@
710
937
  (
711
938
  ): Promise < void >
712
939
  {
713
- deviceType = detectDeviceWithUA() as IDeviceType;
714
- geoLocation = await getUserLocation();
715
- generateElementMap();
716
-
717
940
  // [🐞]
718
941
  logger
719
942
  (
@@ -722,18 +945,7 @@
722
945
  ]
723
946
  );
724
947
 
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
- );
948
+ await refreshAds();
737
949
 
738
950
  return;
739
951
  }
@@ -756,9 +968,37 @@
756
968
  betarenaAdEngineStore.useLocalStorage();
757
969
  // ╭─────
758
970
  // │ NOTE:
971
+ // │ |: Expose public API immediately so host apps can call refreshAds()
972
+ // │ |: without waiting for the delayed initialize().
973
+ // │ |: refreshAds() self-initializes prerequisites via ensureReady() if needed.
974
+ // │ |: Save any existing value so it can be restored when this instance is destroyed.
975
+ // ╰─────
976
+ prevWindowApi = (window as any).betarenaAdEngine;
977
+ (window as any).betarenaAdEngine = { refreshAds };
978
+ // ╭─────
979
+ // │ NOTE:
759
980
  // │ |: Delay initialization to ensure all elements are loaded on page
760
981
  // ╰─────
761
- setTimeout(() => { initialize(); return; }, 1000);
982
+ initTimeoutId = setTimeout
983
+ (
984
+ () =>
985
+ {
986
+ initialize().catch
987
+ (
988
+ (error) =>
989
+ {
990
+ logger
991
+ (
992
+ [
993
+ '🚨 checkpoint ➤ initialize(..) // UNHANDLED ERROR',
994
+ `🔹 [var] ➤ error ${error}`
995
+ ]
996
+ );
997
+ }
998
+ );
999
+ },
1000
+ 1000
1001
+ );
762
1002
  return;
763
1003
  }
764
1004
  );
@@ -776,6 +1016,25 @@
776
1016
  ],
777
1017
  );
778
1018
 
1019
+ isDestroyed = true;
1020
+
1021
+ if (initTimeoutId !== null)
1022
+ clearTimeout(initTimeoutId);
1023
+ ;
1024
+
1025
+ // ╭─────
1026
+ // │ NOTE:
1027
+ // │ |: Only remove/restore the global if this instance still owns it.
1028
+ // │ |: A later-mounted instance or host code may have overwritten it.
1029
+ // ╰─────
1030
+ if ((window as any).betarenaAdEngine?.refreshAds === refreshAds)
1031
+ {
1032
+ if (prevWindowApi !== undefined)
1033
+ (window as any).betarenaAdEngine = prevWindowApi;
1034
+ else
1035
+ delete (window as any).betarenaAdEngine;
1036
+ }
1037
+
779
1038
  // ╭─────
780
1039
  // │ NOTE:
781
1040
  // │ |: Destroy all dynamically created advert components
@@ -807,7 +1066,7 @@
807
1066
 
808
1067
  {#if isStandalone}
809
1068
  <link rel="preconnect" href="https://fonts.googleapis.com">
810
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1069
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
811
1070
  <link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
812
1071
  {/if}
813
1072
 
@@ -106,6 +106,7 @@
106
106
  width=250
107
107
  height=250
108
108
  src={adData.data.media}
109
+ title="Betarena advert"
109
110
  />
110
111
  <!--
111
112
  <video
@@ -262,10 +262,12 @@
262
262
  >
263
263
  <a
264
264
  target='_blank'
265
+ rel='noopener noreferrer'
265
266
  on:click=
266
267
  {
267
268
  () =>
268
269
  {
270
+ if (objectAdvertData.id === undefined) return;
269
271
  new ServiceAdEngine
270
272
  (
271
273
  betarenaEndpoint
@@ -114,11 +114,13 @@
114
114
 
115
115
  <a
116
116
  target='_blank'
117
+ rel='noopener noreferrer'
117
118
  href={objectAdvertData.data?.cta_link}
118
119
  on:click=
119
120
  {
120
121
  () =>
121
122
  {
123
+ if (objectAdvertData.id === undefined) return;
122
124
  new ServiceAdEngine
123
125
  (
124
126
  betarenaEndpoint
@@ -247,16 +247,11 @@
247
247
  ╰─────
248
248
  -->
249
249
  {#if isAdvertCloseBtnShown}
250
- <img
251
- id='close'
252
- src={iconClose}
253
- alt='icon-close'
254
- title='icon-close'
255
- loading='lazy'
256
- class=
257
- "
258
- cursor-pointer
259
- "
250
+ <button
251
+ type="button"
252
+ aria-label="Close advert"
253
+ class="cursor-pointer"
254
+ style="background:none;border:none;padding:0;"
260
255
  on:click=
261
256
  {
262
257
  () =>
@@ -274,7 +269,14 @@
274
269
  return;
275
270
  }
276
271
  }
277
- />
272
+ >
273
+ <img
274
+ class='close-btn'
275
+ src={iconClose}
276
+ alt=''
277
+ loading='lazy'
278
+ />
279
+ </button>
278
280
  {/if}
279
281
 
280
282
  <!--
@@ -295,6 +297,7 @@
295
297
  {
296
298
  () =>
297
299
  {
300
+ if (adData.id === undefined) return;
298
301
  new ServiceAdEngine
299
302
  (
300
303
  betarenaEndpoint
@@ -428,6 +431,7 @@
428
431
  {
429
432
  () =>
430
433
  {
434
+ if (adData.id === undefined) return;
431
435
  new ServiceAdEngine
432
436
  (
433
437
  betarenaEndpoint
@@ -520,7 +524,7 @@
520
524
 
521
525
  img
522
526
  {
523
- &#close
527
+ &.close-btn
524
528
  {
525
529
  /* 📌 position */
526
530
  position: absolute;