@adstage/web-sdk 2.3.6 → 2.4.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.
@@ -6,15 +6,15 @@
6
6
  import { AdStageConfig, BaseModule } from '../../types/config';
7
7
  import { AdType, AdEventType } from '../../types/advertisement';
8
8
  import type { AdSlot, Advertisement } from '../../types/advertisement';
9
- import { CarouselSliderManager } from '../../managers/ads/carousel-slider-manager';
10
- import { TextTransitionManager } from '../../managers/ads/text-transition-manager';
11
- import { ViewableEventTracker } from '../../managers/ads/viewable-event-tracker';
9
+ // 렌더링 관련 매니저/뷰 트래커는 AdRenderer로 이전됨
12
10
  import { AdvertisementEventTracker } from '../../managers/ads/advertisement-event-tracker';
13
11
  import { ViewabilityTracker, VIEWABILITY_STANDARDS } from '../../managers/ads/viewability-tracker';
14
12
  import { BasicFraudDetector } from '../../managers/ads/basic-fraud-detector';
15
13
  import type { ViewabilityMetrics } from '../../managers/ads/viewability-tracker';
16
14
  import { endpoints } from '../../constants/endpoints';
17
15
  import { ApiHeaders } from '../../utils/api-headers';
16
+ // 새로 분리된 클래스들
17
+ import { AdRenderer } from './AdRenderer';
18
18
 
19
19
  export interface AdOptions {
20
20
  width?: string | number;
@@ -38,6 +38,8 @@ export class AdsModule implements BaseModule {
38
38
  private slots = new Map<string, AdSlot>();
39
39
  // Advertisement 이벤트 추적 관련
40
40
  private advertisementEventTracker: AdvertisementEventTracker | null = null;
41
+ // 렌더링 관련
42
+ private adRenderer: AdRenderer | null = null;
41
43
 
42
44
  /**
43
45
  * Ads 모듈 초기화 (동기)
@@ -53,6 +55,9 @@ export class AdsModule implements BaseModule {
53
55
  this.slots
54
56
  );
55
57
 
58
+ // AdRenderer 초기화
59
+ this.adRenderer = new AdRenderer(config.debug || false, this.advertisementEventTracker);
60
+
56
61
  this._isReady = true;
57
62
 
58
63
  if (config.debug) {
@@ -235,8 +240,8 @@ export class AdsModule implements BaseModule {
235
240
  // 고유한 슬롯 ID 생성
236
241
  const slotId = `adstage-${type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
237
242
 
238
- // 즉시 placeholder 생성
239
- this.createAdSlot(container, slotId, type, options);
243
+ // 즉시 placeholder 생성 (AdRenderer 위임)
244
+ this.adRenderer?.createPlaceholder(container, slotId, type, options, this._config);
240
245
 
241
246
  // 광고 슬롯 정보 저장
242
247
  const slot: AdSlot = {
@@ -253,7 +258,7 @@ export class AdsModule implements BaseModule {
253
258
  advertisement: undefined, // 나중에 로드
254
259
  config: { type, ...options },
255
260
  load: async () => this.fetchAdData(type, options).then(ads => ads[0] || null),
256
- render: (ad: Advertisement) => this.renderAdElement(slot, ad),
261
+ render: (ad: Advertisement) => this.adRenderer?.renderAdElement(slot, ad),
257
262
  refresh: async () => this.refreshAdSlot(slot),
258
263
  destroy: () => this.destroy(slotId)
259
264
  };
@@ -272,342 +277,22 @@ export class AdsModule implements BaseModule {
272
277
  return slotId;
273
278
  }
274
279
 
275
- /**
276
- * 즉시 광고 슬롯 생성 (placeholder)
277
- */
278
- private createAdSlot(container: HTMLElement, slotId: string, type: AdType, options: any): void {
279
- const adElement = document.createElement('div');
280
- adElement.id = slotId;
281
- adElement.className = `adstage-slot adstage-${type.toLowerCase()}`;
282
- // 확실한 컨테이너 식별을 위한 데이터 속성 추가
283
- adElement.setAttribute('data-adstage-container', 'true');
284
- adElement.setAttribute('data-adstage-type', type);
285
- adElement.setAttribute('data-adstage-slot', slotId);
286
-
287
- // 스마트한 크기 설정
288
- const { width, height } = this.calculateAdSize(container, type, options);
289
- adElement.style.width = width;
290
- adElement.style.height = height;
291
- adElement.style.border = '1px dashed #ccc';
292
- adElement.style.display = 'flex';
293
- adElement.style.alignItems = 'center';
294
- adElement.style.justifyContent = 'center';
295
- adElement.style.backgroundColor = '#f9f9f9';
296
- adElement.style.color = '#666';
297
- adElement.innerHTML = `<span>Loading ${type} ad...</span>`;
298
-
299
- container.appendChild(adElement);
300
-
301
- if (this._config?.debug) {
302
- console.log(`📦 Placeholder created for slot: ${slotId} (${width} x ${height})`);
303
- }
304
- }
305
-
306
- /**
307
- * 컨테이너와 광고 타입에 따른 스마트한 크기 계산
308
- */
309
- private calculateAdSize(container: HTMLElement, type: AdType, options: any): { width: string; height: string } {
310
- // 사용자가 명시적으로 크기를 지정한 경우
311
- const explicitWidth = options.width;
312
- const explicitHeight = options.height;
313
-
314
- // 너비 처리
315
- let width: string;
316
- if (typeof explicitWidth === 'number') {
317
- width = `${explicitWidth}px`;
318
- } else if (typeof explicitWidth === 'string') {
319
- width = explicitWidth;
320
- } else {
321
- width = '100%'; // 기본값은 100%
322
- }
323
-
324
- // 높이 처리 - 핵심 로직
325
- let height: string;
326
- if (typeof explicitHeight === 'number') {
327
- height = `${explicitHeight}px`;
328
- } else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
329
- // 명시적인 크기 문자열 (예: '200px', '50vh' 등)
330
- height = explicitHeight;
331
- } else {
332
- // 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
333
- const containerHeight = this.getContainerHeight(container);
334
-
335
- if (containerHeight > 0) {
336
- // 컨테이너에 높이가 있으면 100% 사용
337
- height = '100%';
338
- if (this._config?.debug) {
339
- console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
340
- }
341
- } else {
342
- // 컨테이너에 높이가 없으면 타입별 기본값 사용 (나중에 동적 조정됨)
343
- height = this.getDefaultHeightForAdType(type);
344
- if (this._config?.debug) {
345
- console.log(`📏 Using default height ${height} (will be optimized for ${type})`);
346
- }
347
- }
348
- }
349
-
350
- return { width, height };
351
- }
352
-
353
- /**
354
- * 컨테이너의 실제 높이 계산
355
- */
356
- private getContainerHeight(container: HTMLElement): number {
357
- // 현재 계산된 스타일에서 높이 확인
358
- const computedStyle = window.getComputedStyle(container);
359
- const height = parseFloat(computedStyle.height);
360
-
361
- // height가 auto이거나 0이면 다른 방법들 시도
362
- if (!height || height === 0) {
363
- // min-height 확인
364
- const minHeight = parseFloat(computedStyle.minHeight);
365
- if (minHeight > 0) return minHeight;
366
-
367
- // CSS로 설정된 고정 높이 확인
368
- if (container.style.height && container.style.height !== 'auto') {
369
- const styleHeight = parseFloat(container.style.height);
370
- if (styleHeight > 0) return styleHeight;
371
- }
372
-
373
- // 속성으로 설정된 높이 확인
374
- const heightAttr = container.getAttribute('height');
375
- if (heightAttr) {
376
- const attrHeight = parseFloat(heightAttr);
377
- if (attrHeight > 0) return attrHeight;
378
- }
379
- }
380
-
381
- return height || 0;
382
- }
383
-
384
- /**
385
- * 광고 타입별 기본 높이 반환
386
- */
387
- private getDefaultHeightForAdType(type: AdType): string {
388
- switch (type) {
389
- case AdType.BANNER:
390
- return '250px'; // 일반 배너
391
- case AdType.TEXT:
392
- return '120px'; // 텍스트는 좀 더 작게
393
- case AdType.VIDEO:
394
- return '360px'; // 비디오는 16:9 비율 고려
395
- case AdType.NATIVE:
396
- return '200px'; // 네이티브는 중간 크기
397
- case AdType.INTERSTITIAL:
398
- return '400px'; // 전면광고는 크게
399
- default:
400
- return '250px';
401
- }
402
- }
403
-
404
- /**
405
- * 이미지 크기 정보 로드 (프리로딩)
406
- */
407
- private async loadImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
408
- return new Promise((resolve, reject) => {
409
- const img = new Image();
410
- img.onload = () => {
411
- resolve({ width: img.naturalWidth, height: img.naturalHeight });
412
- };
413
- img.onerror = () => {
414
- reject(new Error(`Failed to load image: ${imageUrl}`));
415
- };
416
- img.src = imageUrl;
417
- });
418
- }
280
+ // createAdSlot 제거: AdRenderer.createPlaceholder 사용
419
281
 
420
282
  /**
421
283
  * 여러 광고의 최적 컨테이너 크기 계산 (동적 크기 조정)
422
284
  */
423
- private async calculateOptimalContainerSize(
424
- advertisements: Advertisement[],
425
- containerWidth: number,
426
- adType: AdType
427
- ): Promise<{ width: string; height: string; aspectRatio: number }> {
428
- if (!advertisements.length || adType !== AdType.BANNER) {
429
- // 배너가 아니거나 광고가 없으면 기본값
430
- return {
431
- width: '100%',
432
- height: this.getDefaultHeightForAdType(adType),
433
- aspectRatio: 16/9
434
- };
435
- }
436
-
437
- try {
438
- // 모든 배너 이미지의 크기 정보 로드
439
- const imageDimensions = await Promise.allSettled(
440
- advertisements
441
- .filter(ad => ad.imageUrl)
442
- .map(ad => this.loadImageDimensions(ad.imageUrl!))
443
- );
444
-
445
- const validDimensions = imageDimensions
446
- .filter((result): result is PromiseFulfilledResult<{ width: number; height: number }> =>
447
- result.status === 'fulfilled'
448
- )
449
- .map(result => result.value);
450
-
451
- if (validDimensions.length === 0) {
452
- // 이미지 로드 실패시 기본값
453
- return {
454
- width: '100%',
455
- height: this.getDefaultHeightForAdType(adType),
456
- aspectRatio: 16/9
457
- };
458
- }
459
-
460
- // 최적 전략 선택
461
- const strategy = this.selectOptimalSizeStrategy(validDimensions);
462
- const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
463
-
464
- if (this._config?.debug) {
465
- console.log(`📐 Optimal container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
466
- }
467
-
468
- return {
469
- width: '100%',
470
- height: `${optimalHeight}px`,
471
- aspectRatio: containerWidth / optimalHeight
472
- };
473
-
474
- } catch (error) {
475
- console.warn('Failed to calculate optimal size, using defaults:', error);
476
- return {
477
- width: '100%',
478
- height: this.getDefaultHeightForAdType(adType),
479
- aspectRatio: 16/9
480
- };
481
- }
482
- }
285
+ // calculateOptimalContainerSize 제거: AdRenderer.calculateOptimalContainerSize 사용
483
286
 
484
287
  /**
485
288
  * 최적 크기 조정 전략 선택
486
289
  */
487
- private selectOptimalSizeStrategy(dimensions: { width: number; height: number }[]): 'average' | 'common' | 'dominant' {
488
- const aspectRatios = dimensions.map(d => d.width / d.height);
489
-
490
- // 1. 공통 비율이 있는지 확인 (±0.1 허용)
491
- const ratioGroups = new Map<string, number>();
492
- aspectRatios.forEach(ratio => {
493
- const roundedRatio = Math.round(ratio * 10) / 10;
494
- const key = roundedRatio.toString();
495
- ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
496
- });
497
-
498
- const maxGroup = Math.max(...ratioGroups.values());
499
- const totalImages = dimensions.length;
500
-
501
- // 70% 이상이 비슷한 비율이면 dominant 전략
502
- if (maxGroup / totalImages >= 0.7) {
503
- return 'dominant';
504
- }
505
-
506
- // 표준 비율들이 많으면 common 전략
507
- const standardRatios = [16/9, 4/3, 1/1, 3/2];
508
- const standardCount = aspectRatios.filter(ratio =>
509
- standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)
510
- ).length;
511
-
512
- if (standardCount / totalImages >= 0.5) {
513
- return 'common';
514
- }
515
-
516
- // 기본은 평균 전략
517
- return 'average';
518
- }
290
+ // selectOptimalSizeStrategy 제거: AdRenderer 내부 구현 사용
519
291
 
520
292
  /**
521
293
  * 전략에 따른 최적 높이 계산
522
294
  */
523
- private calculateOptimalHeight(
524
- dimensions: { width: number; height: number }[],
525
- containerWidth: number,
526
- strategy: 'average' | 'common' | 'dominant'
527
- ): number {
528
- const aspectRatios = dimensions.map(d => d.width / d.height);
529
-
530
- switch (strategy) {
531
- case 'dominant':
532
- // 가장 많은 비율을 기준으로
533
- const ratioGroups = new Map<string, { ratio: number; count: number }>();
534
- aspectRatios.forEach(ratio => {
535
- const roundedRatio = Math.round(ratio * 10) / 10;
536
- const key = roundedRatio.toString();
537
- const existing = ratioGroups.get(key);
538
- if (existing) {
539
- existing.count++;
540
- } else {
541
- ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
542
- }
543
- });
544
-
545
- const dominantGroup = Array.from(ratioGroups.values())
546
- .reduce((max, current) => current.count > max.count ? current : max);
547
-
548
- return Math.round(containerWidth / dominantGroup.ratio);
549
-
550
- case 'common':
551
- // 표준 비율 중 가장 적합한 것 선택
552
- const standardRatios = [
553
- { ratio: 16/9, name: '16:9' },
554
- { ratio: 4/3, name: '4:3' },
555
- { ratio: 1/1, name: '1:1' },
556
- { ratio: 3/2, name: '3:2' }
557
- ];
558
-
559
- const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
560
- const bestStandard = standardRatios.reduce((best, current) =>
561
- Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best
562
- );
563
-
564
- if (this._config?.debug) {
565
- console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
566
- }
567
-
568
- return Math.round(containerWidth / bestStandard.ratio);
569
-
570
- case 'average':
571
- default:
572
- // 평균 비율 사용
573
- const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
574
- return Math.round(containerWidth / averageRatio);
575
- }
576
- }
577
-
578
- /**
579
- * 개별 이미지에 최적화된 렌더링 스타일 적용
580
- */
581
- private applyOptimizedImageStyle(
582
- img: HTMLImageElement,
583
- imageAspectRatio: number,
584
- containerAspectRatio: number
585
- ): void {
586
- const ratio = imageAspectRatio / containerAspectRatio;
587
-
588
- if (Math.abs(ratio - 1) < 0.1) {
589
- // 비율이 거의 같으면 cover 사용
590
- img.style.objectFit = 'cover';
591
- img.style.objectPosition = 'center';
592
- } else if (ratio > 1.3) {
593
- // 이미지가 훨씬 가로형이면 contain으로 전체 보이기
594
- img.style.objectFit = 'contain';
595
- img.style.objectPosition = 'center';
596
- img.style.backgroundColor = '#f0f0f0'; // 빈 공간 배경색
597
- } else if (ratio < 0.7) {
598
- // 이미지가 훨씬 세로형이면 cover로 채우기
599
- img.style.objectFit = 'cover';
600
- img.style.objectPosition = 'center';
601
- } else {
602
- // 적당한 차이면 스마트 cover
603
- img.style.objectFit = 'cover';
604
- img.style.objectPosition = 'center';
605
- }
606
-
607
- if (this._config?.debug) {
608
- console.log(`🎨 Image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
609
- }
610
- }
295
+ // calculateOptimalHeight 제거: AdRenderer 내부 구현 사용
611
296
 
612
297
  /**
613
298
  * 백그라운드에서 광고 콘텐츠 로드
@@ -618,22 +303,22 @@ export class AdsModule implements BaseModule {
618
303
  const adstageData = await this.fetchAdData(slot.adType, slot.config);
619
304
 
620
305
  if (!adstageData || adstageData.length === 0) {
621
- this.renderFallback(slot);
306
+ this.adRenderer?.renderFallback(slot);
622
307
  return;
623
308
  }
624
309
 
625
310
  // 🆕 동적 크기 조정: 배너 광고의 경우 이미지 크기 기반으로 컨테이너 최적화
626
311
  if (slot.adType === AdType.BANNER && adstageData.length > 0) {
627
- await this.optimizeContainerForBannerAds(slot, adstageData);
312
+ await this.adRenderer?.optimizeContainerForBannerAds(slot, adstageData);
628
313
  }
629
314
 
630
315
  // 광고가 여러 개이거나 autoSlide 옵션이 있으면 슬라이더로 렌더링
631
316
  if (adstageData.length > 1 || (slot.config as any)?.autoSlide) {
632
- await this.renderAdSlider(slot, adstageData);
317
+ await this.adRenderer?.renderAdSlider(slot, adstageData);
633
318
  } else {
634
319
  // 광고가 1개면 일반 렌더링
635
320
  slot.advertisement = adstageData[0];
636
- await this.renderAdElement(slot, adstageData[0]);
321
+ await this.adRenderer?.renderAdElement(slot, adstageData[0]);
637
322
 
638
323
  // ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
639
324
  this.startBasicViewabilityTracking(slot, adstageData[0]);
@@ -646,41 +331,14 @@ export class AdsModule implements BaseModule {
646
331
  }
647
332
  } catch (error) {
648
333
  console.error(`❌ Failed to load ad for slot: ${slot.id}`, error);
649
- this.renderFallback(slot);
334
+ this.adRenderer?.renderFallback(slot);
650
335
  }
651
336
  }
652
337
 
653
338
  /**
654
339
  * 배너 광고를 위한 컨테이너 최적화
655
340
  */
656
- private async optimizeContainerForBannerAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
657
- try {
658
- const container = document.getElementById(slot.containerId);
659
- const adElement = document.getElementById(slot.id);
660
-
661
- if (!container || !adElement) return;
662
-
663
- // 현재 컨테이너 너비 확인
664
- const containerWidth = container.getBoundingClientRect().width || 300;
665
-
666
- // 최적 크기 계산
667
- const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth, slot.adType);
668
-
669
- // 컨테이너 크기 동적 조정
670
- adElement.style.height = optimalSize.height;
671
-
672
- // 슬롯 정보 업데이트
673
- (slot as any).optimizedHeight = optimalSize.height;
674
- (slot as any).aspectRatio = optimalSize.aspectRatio;
675
-
676
- if (this._config?.debug) {
677
- console.log(`🔧 Container optimized for ${advertisements.length} banner ads: ${optimalSize.height}`);
678
- }
679
-
680
- } catch (error) {
681
- console.warn('Container optimization failed, using default size:', error);
682
- }
683
- }
341
+ // optimizeContainerForBannerAds 제거: AdRenderer.optimizeContainerForBannerAds 사용
684
342
 
685
343
  /**
686
344
  * 기본 viewability 추적 시작
@@ -753,121 +411,12 @@ export class AdsModule implements BaseModule {
753
411
  /**
754
412
  * Fallback 광고 렌더링 - AdStage 확실한 컨테이너 우선 탐지
755
413
  */
756
- private renderFallback(slot: AdSlot): void {
757
- const element = document.getElementById(slot.id);
758
- if (element) {
759
- // 1순위: AdStage가 생성한 확실한 컨테이너들 (데이터 속성 기반)
760
- const adstageContainers = [
761
- element.querySelector('[data-adstage-container="true"]'), // 내부 AdStage 컨테이너
762
- element.closest('[data-adstage-container="true"]'), // 상위 AdStage 컨테이너
763
- element, // 자기 자신이 AdStage 컨테이너인 경우
764
- ].filter(el => el && el.hasAttribute('data-adstage-container'));
765
-
766
- // 2순위: AdStage 클래스 기반 컨테이너들
767
- const classBasedContainers = [
768
- element.closest('.adstage-slot'),
769
- element.closest('.adstage-banner'),
770
- element.closest('.adstage-text'),
771
- element.closest('.adstage-video'),
772
- element.closest('.adstage-native'),
773
- element.closest('.adstage-interstitial'),
774
- ].filter(Boolean);
775
-
776
- // 3순위: 일반적인 광고 컨테이너 패턴들 (fallback)
777
- const generalContainers = [
778
- element.closest('[class*="ad"]'),
779
- element.closest('[class*="banner"]'),
780
- element.closest('[class*="container"]'),
781
- element.closest('div[style*="height"]'),
782
- element.closest('div[style*="min-height"]'),
783
- element.parentElement
784
- ].filter(Boolean);
785
-
786
- // 우선순위에 따라 컨테이너 선택
787
- const possibleContainers = [
788
- ...adstageContainers,
789
- ...classBasedContainers,
790
- ...generalContainers
791
- ];
792
-
793
- // 가장 적절한 컨테이너 선택
794
- const targetContainer = possibleContainers[0] as HTMLElement;
795
-
796
- if (targetContainer) {
797
- // 컨테이너 타입 로깅
798
- let containerType = 'unknown';
799
- if (targetContainer.hasAttribute('data-adstage-container')) {
800
- containerType = 'adstage-official';
801
- } else if (targetContainer.classList.contains('adstage-slot')) {
802
- containerType = 'adstage-class';
803
- } else {
804
- containerType = 'generic';
805
- }
806
-
807
- targetContainer.style.cssText += `
808
- height: 0px !important;
809
- min-height: 0px !important;
810
- padding: 0px !important;
811
- margin: 0px !important;
812
- border: none !important;
813
- overflow: hidden !important;
814
- display: block !important;
815
- `;
816
-
817
- // 내부 모든 요소 제거
818
- targetContainer.innerHTML = '';
819
-
820
- // 빈 상태임을 표시하는 속성 추가
821
- targetContainer.setAttribute('data-adstage-empty', 'true');
822
-
823
- if (this._config?.debug) {
824
- console.warn(`⚠️ Ad container collapsed (${containerType}): ${slot.id}`, targetContainer);
825
- }
826
- } else {
827
- // 컨테이너를 찾지 못한 경우 새로운 빈 컨테이너 생성
828
- this.createEmptyContainer(slot);
829
- }
830
- }
831
-
832
- // 슬롯 상태 업데이트 (제거하지 않고 빈 상태로 마킹)
833
- slot.advertisement = undefined;
834
- (slot as any).isEmpty = true;
835
- }
414
+ // renderFallback 제거: AdRenderer.renderFallback 사용
836
415
 
837
416
  /**
838
417
  * 빈 컨테이너 생성 (컨테이너를 찾지 못한 경우)
839
418
  */
840
- private createEmptyContainer(slot: AdSlot): void {
841
- const originalContainer = document.getElementById(slot.containerId);
842
- if (originalContainer) {
843
- // 기존 내용 제거
844
- originalContainer.innerHTML = '';
845
-
846
- // 빈 AdStage 컨테이너 생성
847
- const emptyElement = document.createElement('div');
848
- emptyElement.id = slot.id;
849
- emptyElement.className = 'adstage-slot adstage-empty';
850
- emptyElement.setAttribute('data-adstage-container', 'true');
851
- emptyElement.setAttribute('data-adstage-empty', 'true');
852
- emptyElement.setAttribute('data-adstage-slot', slot.id);
853
-
854
- emptyElement.style.cssText = `
855
- height: 0px !important;
856
- min-height: 0px !important;
857
- padding: 0px !important;
858
- margin: 0px !important;
859
- border: none !important;
860
- overflow: hidden !important;
861
- display: block !important;
862
- `;
863
-
864
- originalContainer.appendChild(emptyElement);
865
-
866
- if (this._config?.debug) {
867
- console.warn(`⚠️ Created empty AdStage container: ${slot.id}`);
868
- }
869
- }
870
- }
419
+ // createEmptyContainer 제거: AdRenderer 내부 구현 사용
871
420
 
872
421
  /**
873
422
  * 광고 데이터 가져오기
@@ -922,257 +471,17 @@ export class AdsModule implements BaseModule {
922
471
  /**
923
472
  * 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
924
473
  */
925
- private async renderAdSlider(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
926
- const container = document.getElementById(slot.containerId);
927
- if (!container) {
928
- throw new Error(`Container not found: ${slot.containerId}`);
929
- }
930
-
931
- // 이벤트 추적 콜백 함수 (중복 노출 방지 포함)
932
- const trackEventCallback = async (adId: string, slotId: string, eventType: AdEventType) => {
933
- // 노출 이벤트인 경우 중복 확인
934
- if (eventType === AdEventType.VIEWABLE) {
935
- if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
936
- if (this._config?.debug) {
937
- console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
938
- }
939
- return; // 중복 노출이면 추적하지 않음
940
- }
941
-
942
- if (this._config?.debug) {
943
- console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
944
- }
945
- }
946
-
947
- // 실제 API 호출로 이벤트 전송
948
- if (this.advertisementEventTracker) {
949
- try {
950
- await this.advertisementEventTracker.trackAdvertisementEvent(
951
- adId,
952
- slotId,
953
- eventType,
954
- {} // 기본 메타데이터
955
- );
956
-
957
- if (this._config?.debug) {
958
- console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
959
- }
960
- } catch (error) {
961
- if (this._config?.debug) {
962
- console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
963
- }
964
- }
965
- }
966
- };
967
-
968
- let sliderElement: HTMLElement;
969
-
970
- // 최적화된 슬라이더 옵션 준비
971
- const optimizedSliderOptions = {
972
- autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
973
- ...slot.config,
974
- // 🆕 동적 크기 정보 전달
975
- optimizedHeight: (slot as any).optimizedHeight,
976
- aspectRatio: (slot as any).aspectRatio
977
- };
978
-
979
- // 텍스트 광고는 TextTransitionManager 사용, 그 외는 CarouselSliderManager 사용
980
- if (slot.adType === AdType.TEXT) {
981
- sliderElement = TextTransitionManager.createTextTransitionContainer(
982
- slot,
983
- advertisements,
984
- optimizedSliderOptions,
985
- trackEventCallback
986
- );
987
-
988
- if (this._config?.debug) {
989
- console.log(`✨ Text transition created for TEXT slot: ${slot.id} with ${advertisements.length} ads`);
990
- }
991
- } else {
992
- sliderElement = CarouselSliderManager.createSliderContainer(
993
- slot,
994
- advertisements,
995
- optimizedSliderOptions,
996
- trackEventCallback
997
- );
998
-
999
- if (this._config?.debug) {
1000
- console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads (optimized: ${(slot as any).optimizedHeight || 'default'})`);
1001
- }
1002
- }
1003
-
1004
- // 기존 내용 제거하고 슬라이더 추가
1005
- container.innerHTML = '';
1006
- container.appendChild(sliderElement);
1007
-
1008
- // ✅ 신규: 슬라이더에서도 첫 번째 광고에 대해 ViewabilityTracker 시작
1009
- if (advertisements.length > 0) {
1010
- this.startBasicViewabilityTracking(slot, advertisements[0]);
1011
-
1012
- if (this._config?.debug) {
1013
- console.log(`🎯 Viewability tracking started for first ad in slider: ${slot.id}`);
1014
- }
1015
- }
1016
- }
474
+ // renderAdSlider 제거: AdRenderer.renderAdSlider 사용
1017
475
 
1018
476
  /**
1019
477
  * 광고 렌더링 (단일 광고용)
1020
478
  */
1021
- private async renderAd(slot: AdSlot): Promise<void> {
1022
- if (!slot.advertisement) {
1023
- throw new Error('No advertisement to render');
1024
- }
1025
-
1026
- await this.renderAdElement(slot, slot.advertisement);
1027
- slot.isLoaded = true;
1028
- }
479
+ // renderAd 제거: AdRenderer.renderAd 사용
1029
480
 
1030
481
  /**
1031
482
  * 광고 요소 렌더링 (기본 구현)
1032
483
  */
1033
- private async renderAdElement(slot: AdSlot, ad: Advertisement): Promise<void> {
1034
- const container = document.getElementById(slot.containerId);
1035
- if (!container) return;
1036
-
1037
- // 기본 HTML 구조 생성
1038
- const adElement = document.createElement('div');
1039
- adElement.className = 'adstage-ad';
1040
-
1041
- // 스마트한 크기 설정 - 최적화된 크기가 있으면 사용
1042
- const optimizedHeight = (slot as any).optimizedHeight;
1043
- const containerElement = container.parentElement || container;
1044
-
1045
- if (optimizedHeight) {
1046
- adElement.style.width = '100%';
1047
- adElement.style.height = optimizedHeight;
1048
- } else {
1049
- const { width, height } = this.calculateAdSize(containerElement, slot.adType, slot.config || {});
1050
- adElement.style.width = width;
1051
- adElement.style.height = height;
1052
- }
1053
-
1054
- // 광고 타입별 렌더링
1055
- switch (slot.adType) {
1056
- case AdType.BANNER:
1057
- if (ad.imageUrl) {
1058
- await this.renderOptimizedBannerImage(adElement, ad, slot);
1059
- }
1060
- break;
1061
-
1062
- case AdType.TEXT:
1063
- const textDiv = document.createElement('div');
1064
- textDiv.innerHTML = `
1065
- <h3>${ad.title}</h3>
1066
- ${ad.description ? `<p>${ad.description}</p>` : ''}
1067
- ${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
1068
- `;
1069
- adElement.appendChild(textDiv);
1070
- break;
1071
-
1072
- case AdType.VIDEO:
1073
- if (ad.videoUrl) {
1074
- const video = document.createElement('video');
1075
- video.src = ad.videoUrl;
1076
- video.controls = true;
1077
- video.style.width = '100%';
1078
- video.style.height = '100%';
1079
- adElement.appendChild(video);
1080
- }
1081
- break;
1082
-
1083
- default:
1084
- adElement.innerHTML = `<div>${ad.title}</div>`;
1085
- }
1086
-
1087
- // 클릭 이벤트 추가
1088
- if (ad.linkUrl) {
1089
- adElement.style.cursor = 'pointer';
1090
- adElement.addEventListener('click', () => {
1091
- window.open(ad.linkUrl, '_blank');
1092
- });
1093
- }
1094
-
1095
- container.innerHTML = '';
1096
- container.appendChild(adElement);
1097
- }
1098
-
1099
- /**
1100
- * 최적화된 배너 이미지 렌더링
1101
- */
1102
- private async renderOptimizedBannerImage(adElement: HTMLElement, ad: Advertisement, slot: AdSlot): Promise<void> {
1103
- try {
1104
- // 사용자가 크기를 지정했는지 확인
1105
- const configWidth = slot.config?.width;
1106
- const configHeight = slot.config?.height;
1107
-
1108
- const hasUserDefinedWidth = configWidth &&
1109
- (typeof configWidth === 'number' || (typeof configWidth === 'string' && configWidth !== '100%'));
1110
- const hasUserDefinedHeight = configHeight &&
1111
- (typeof configHeight === 'number' || (typeof configHeight === 'string' && configHeight !== 'auto'));
1112
- const hasUserDefinedSize = hasUserDefinedWidth || hasUserDefinedHeight;
1113
-
1114
- // 이미지 요소 생성
1115
- const img = document.createElement('img');
1116
- img.src = ad.imageUrl!;
1117
- img.alt = ad.title;
1118
-
1119
- if (hasUserDefinedSize) {
1120
- // 🎯 사용자가 크기를 지정한 경우: 컨테이너에 꽉 차도록 설정
1121
- img.style.width = '100%';
1122
- img.style.height = '100%';
1123
- img.style.objectFit = 'cover'; // 컨테이너에 꽉 찬 상태로 비율 유지
1124
- img.style.objectPosition = 'center';
1125
-
1126
- if (this._config?.debug) {
1127
- console.log(`🎯 User-defined size detected: filling container completely`);
1128
- }
1129
- } else {
1130
- // 사용자가 크기를 지정하지 않은 경우: 동적 최적화 적용
1131
- const imageDimensions = await this.loadImageDimensions(ad.imageUrl!);
1132
- const imageAspectRatio = imageDimensions.width / imageDimensions.height;
1133
- const containerAspectRatio = (slot as any).aspectRatio || 16/9;
1134
-
1135
- img.style.width = '100%';
1136
- img.style.height = '100%';
1137
-
1138
- // 🎨 최적화된 스타일 적용
1139
- this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
1140
-
1141
- if (this._config?.debug) {
1142
- console.log(`🖼️ Optimized banner image loaded: ${imageDimensions.width}x${imageDimensions.height} (ratio: ${imageAspectRatio.toFixed(2)})`);
1143
- }
1144
- }
1145
-
1146
- // 이미지 로드 완료 처리
1147
- img.onload = () => {
1148
- if (this._config?.debug) {
1149
- console.log(`✅ Banner image loaded successfully`);
1150
- }
1151
- };
1152
-
1153
- // 에러 처리
1154
- img.onerror = () => {
1155
- console.warn(`Failed to load banner image: ${ad.imageUrl}`);
1156
- // 폴백 텍스트 표시
1157
- adElement.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; background: #f0f0f0; color: #666;">${ad.title}</div>`;
1158
- };
1159
-
1160
- adElement.appendChild(img);
1161
-
1162
- } catch (error) {
1163
- console.warn('Failed to optimize banner image, using fallback:', error);
1164
-
1165
- // 기본 이미지 렌더링 (폴백)
1166
- const img = document.createElement('img');
1167
- img.src = ad.imageUrl!;
1168
- img.alt = ad.title;
1169
- img.style.width = '100%';
1170
- img.style.height = '100%';
1171
- img.style.objectFit = 'cover';
1172
- img.style.objectPosition = 'center'; // 🎯 폴백 이미지도 중앙 정렬
1173
- adElement.appendChild(img);
1174
- }
1175
- }
484
+ // renderAdElement 제거: AdRenderer.renderAdElement 사용
1176
485
 
1177
486
  /**
1178
487
  * 광고 슬롯 새로고침
@@ -1184,7 +493,7 @@ export class AdsModule implements BaseModule {
1184
493
 
1185
494
  if (newAdData && newAdData.length > 0) {
1186
495
  slot.advertisement = newAdData[0]; // 첫 번째 광고로 업데이트
1187
- await this.renderAd(slot);
496
+ await this.adRenderer?.renderAd(slot);
1188
497
 
1189
498
  // 새로운 노출 추적
1190
499
  if (this.advertisementEventTracker) {