@adstage/web-sdk 2.5.2 → 2.6.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 (38) hide show
  1. package/README.md +59 -0
  2. package/dist/index.cjs.js +2073 -1050
  3. package/dist/index.d.ts +55 -12
  4. package/dist/index.esm.js +2073 -1050
  5. package/dist/index.standalone.js +2073 -1050
  6. package/package.json +1 -1
  7. package/src/constants/endpoints.ts +0 -1
  8. package/src/core/{AdStage.ts → adstage.ts} +36 -8
  9. package/src/index.ts +9 -3
  10. package/src/managers/ads/advertisement-event-tracker.ts +15 -11
  11. package/src/managers/ads/carousel-slider-manager.ts +91 -19
  12. package/src/managers/ads/slider-event-tracker.ts +57 -0
  13. package/src/managers/ads/text-transition-manager.ts +91 -26
  14. package/src/modules/ads/ad-renderer.ts +259 -0
  15. package/src/modules/ads/{AdsModule.ts → ads-module.ts} +202 -21
  16. package/src/modules/ads/interfaces/i-ad-renderer.ts +77 -0
  17. package/src/modules/ads/renderers/banner-ad-renderer.ts +414 -0
  18. package/src/modules/ads/renderers/base-ad-renderer.ts +340 -0
  19. package/src/modules/ads/renderers/interstitial-ad-renderer.ts +256 -0
  20. package/src/modules/ads/renderers/native-ad-renderer.ts +154 -0
  21. package/src/modules/ads/renderers/text-ad-renderer.ts +120 -0
  22. package/src/modules/ads/renderers/video-ad-renderer.ts +433 -0
  23. package/src/modules/config/{ConfigModule.ts → config-module.ts} +1 -5
  24. package/src/react/{AdStageProvider.tsx → ad-stage-provider.tsx} +1 -1
  25. package/src/react/index.ts +2 -2
  26. package/src/types/config.ts +2 -184
  27. package/src/utils/ad-click-handler.ts +155 -0
  28. package/src/utils/text-ad-utils.ts +37 -0
  29. package/src/dummy/ads_dummy.json +0 -84
  30. package/src/modules/ads/AdRenderer.ts +0 -735
  31. package/src/renderers/banner-renderer.ts +0 -35
  32. package/src/renderers/base-renderer.ts +0 -209
  33. package/src/renderers/index.ts +0 -71
  34. package/src/renderers/interstitial-renderer.ts +0 -70
  35. package/src/renderers/native-renderer.ts +0 -35
  36. package/src/renderers/text-renderer.ts +0 -94
  37. package/src/renderers/video-renderer.ts +0 -63
  38. /package/src/modules/events/{EventsModule.ts → events-module.ts} +0 -0
package/dist/index.cjs.js CHANGED
@@ -33,47 +33,6 @@ var DeviceType;
33
33
  DeviceType["TABLET"] = "TABLET";
34
34
  })(DeviceType || (DeviceType = {}));
35
35
 
36
- /**
37
- * 단순한 VIEWABLE 이벤트 중복 방지 관리 클래스
38
- * - 세션당 동일 광고 1회만 VIEWABLE 이벤트 허용
39
- * - 메모리 기반 추적으로 단순화
40
- */
41
- class ViewableEventTracker {
42
- /**
43
- * 중복 viewable 이벤트 여부 확인
44
- */
45
- static isDuplicateViewable(adId, slotId, debug = false) {
46
- const key = `${adId}_${slotId}`;
47
- // 이미 VIEWABLE 이벤트가 발생한 광고인지 확인
48
- if (ViewableEventTracker.viewableTracker.has(key)) {
49
- if (debug) {
50
- console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
51
- }
52
- return true;
53
- }
54
- // 새로운 VIEWABLE 이벤트 기록
55
- ViewableEventTracker.viewableTracker.add(key);
56
- if (debug) {
57
- console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
58
- }
59
- return false;
60
- }
61
- /**
62
- * 모든 추적 데이터 정리 (디버그용)
63
- */
64
- static clear() {
65
- ViewableEventTracker.viewableTracker.clear();
66
- }
67
- /**
68
- * 특정 광고의 viewable 추적 초기화 (디버그용)
69
- */
70
- static clearAdViewable(adId, slotId) {
71
- const key = `${adId}_${slotId}`;
72
- ViewableEventTracker.viewableTracker.delete(key);
73
- }
74
- }
75
- ViewableEventTracker.viewableTracker = new Set();
76
-
77
36
  /**
78
37
  * SSR 안전한 DOM API 래퍼 클래스
79
38
  * 서버사이드 렌더링 환경에서 DOM API 접근 시 오류를 방지합니다.
@@ -499,7 +458,7 @@ class ApiHeaders {
499
458
  * AdStage SDK - 버전 정보 유틸리티
500
459
  */
501
460
  // package.json에서 버전 정보 가져오기 (빌드 시 자동으로 교체됨)
502
- const SDK_VERSION$1 = '"2.5.2"';
461
+ const SDK_VERSION$1 = '"2.6.0"';
503
462
  /**
504
463
  * SDK 버전 정보 반환
505
464
  */
@@ -528,15 +487,6 @@ class AdvertisementEventTracker {
528
487
  if (this.debug) {
529
488
  console.log(`🚀 AdvertisementEventTracker: Processing ${eventType} event for ad ${adId} in slot ${slotId}`);
530
489
  }
531
- // VIEWABLE 이벤트 중복 확인
532
- if (eventType === AdEventType.VIEWABLE) {
533
- if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
534
- if (this.debug) {
535
- console.log(`⏭️ Skipping duplicate viewable event for ad ${adId} in slot ${slotId}`);
536
- }
537
- return;
538
- }
539
- }
540
490
  // 현재 슬롯 정보 가져오기
541
491
  const slot = this.slots.get(slotId);
542
492
  // 디바이스 정보 수집
@@ -580,6 +530,12 @@ class AdvertisementEventTracker {
580
530
  headers,
581
531
  eventData
582
532
  });
533
+ console.log(`🌐 Full API call details:`, {
534
+ method: 'POST',
535
+ url,
536
+ hasApiKey: !!this.apiKey,
537
+ bodySize: JSON.stringify(eventData).length
538
+ });
583
539
  }
584
540
  const response = await fetch(url, {
585
541
  method: 'POST',
@@ -601,7 +557,15 @@ class AdvertisementEventTracker {
601
557
  }
602
558
  }
603
559
  catch (error) {
604
- console.error('Failed to track advertisement event:', error);
560
+ console.error('Failed to track advertisement event:', error);
561
+ console.error('🔍 Debug info:', {
562
+ baseUrl: this.baseUrl,
563
+ apiKey: this.apiKey ? `${this.apiKey.substring(0, 8)}...` : 'NOT_SET',
564
+ url: `${this.baseUrl}/advertisements/events/${adId}/${eventType}`,
565
+ eventType,
566
+ adId,
567
+ slotId
568
+ });
605
569
  }
606
570
  }
607
571
  /**
@@ -724,7 +688,6 @@ class EndpointBuilder {
724
688
  */
725
689
  setBaseUrl(url) {
726
690
  this.baseUrl = url;
727
- console.log('🔄 API endpoint changed:', url);
728
691
  }
729
692
  /**
730
693
  * 기본 URL 반환
@@ -745,454 +708,152 @@ class EndpointBuilder {
745
708
  const endpoints = new EndpointBuilder();
746
709
 
747
710
  /**
748
- * 기본 광고 렌더러 추상 클래스
711
+ * 슬라이더 이벤트 추적 공통 유틸리티
712
+ * - 모든 슬라이더 타입에서 일관된 VIEWABLE 이벤트 추적
713
+ * - 중복 방지는 상위 레벨에서 처리
749
714
  */
750
- class BaseAdRenderer {
751
- constructor(trackEvent) {
752
- this.trackEvent = trackEvent;
753
- }
754
- /**
755
- * 공통 클릭 이벤트 핸들러 (SSR 안전)
756
- */
757
- addClickHandler(element, ad, slot) {
758
- DOMUtils.safeAddEventListener(element, 'click', () => {
759
- this.trackEvent?.(ad._id, slot.id, 'CLICK');
760
- if (ad.linkUrl) {
761
- DOMUtils.safeWindowOpen(ad.linkUrl, '_blank');
762
- }
763
- });
764
- }
765
- /**
766
- * 공통 스타일 적용 유틸리티 (SSR 안전)
767
- */
768
- applyStyles(element, styles) {
769
- DOMUtils.safeApplyStyles(element, styles);
770
- }
771
- /**
772
- * 크기 값 파싱 유틸리티 (px, %, number 지원)
773
- */
774
- parseSizeValue(value) {
775
- if (!value)
776
- return undefined;
777
- if (typeof value === 'number') {
778
- return value > 0 ? `${value}px` : undefined;
779
- }
780
- if (typeof value === 'string') {
781
- const trimmed = value.trim();
782
- if (!trimmed)
783
- return undefined;
784
- // 퍼센트 값 처리
785
- if (trimmed.endsWith('%')) {
786
- const percent = parseFloat(trimmed);
787
- return !isNaN(percent) && percent > 0 ? trimmed : undefined;
788
- }
789
- // px 값 처리 (px 단위 포함/미포함 모두 지원)
790
- const numValue = trimmed.endsWith('px')
791
- ? parseFloat(trimmed.slice(0, -2))
792
- : parseFloat(trimmed);
793
- return !isNaN(numValue) && numValue > 0 ? `${numValue}px` : undefined;
794
- }
795
- return undefined;
796
- }
797
- /**
798
- * 기본 컨테이너 스타일 (사용자 지정 크기만 적용)
799
- */
800
- getBaseContainerStyles(slot) {
801
- const styles = {
802
- cursor: 'pointer',
803
- position: 'relative',
804
- overflow: 'hidden',
805
- };
806
- // 사용자가 지정한 크기가 있을 때만 적용
807
- const parsedWidth = this.parseSizeValue(slot.width);
808
- const parsedHeight = this.parseSizeValue(slot.height);
809
- if (parsedWidth) {
810
- styles.width = parsedWidth;
811
- }
812
- if (parsedHeight) {
813
- styles.height = parsedHeight;
814
- }
815
- return styles;
816
- }
715
+ class SliderEventTracker {
817
716
  /**
818
- * 이미지 스타일 (고유 사이즈 유지, 사용자 지정 크기 우선)
717
+ * 슬라이드 변경 VIEWABLE 이벤트 추적
718
+ * @param advertisement 현재 슬라이드의 광고
719
+ * @param slot 광고 슬롯
720
+ * @param slideIndex 현재 슬라이드 인덱스
721
+ * @param trackEventCallback 이벤트 추적 콜백
722
+ * @param debug 디버그 모드
819
723
  */
820
- getImageStyles(slot) {
821
- const styles = {
822
- display: 'block',
823
- 'max-width': '100%',
824
- height: 'auto',
825
- 'object-position': 'center', // 🎯 이미지 항상 중앙 정렬
826
- };
827
- // 사용자가 컨테이너 크기를 지정한 경우에만 크기 제한
828
- const parsedWidth = this.parseSizeValue(slot?.width);
829
- const parsedHeight = this.parseSizeValue(slot?.height);
830
- if (parsedWidth && parsedHeight) {
831
- styles.width = '100%';
832
- styles.height = '100%';
833
- styles['object-fit'] = 'cover';
834
- styles['object-position'] = 'center'; // 🎯 크기 조정 시에도 중앙 정렬
724
+ static trackSlideViewable(advertisement, slot, slideIndex, trackEventCallback, debug = false) {
725
+ if (debug) {
726
+ console.log(`🎯 Triggering VIEWABLE event for slide change: ad ${advertisement._id} (index: ${slideIndex}) in slot: ${slot.id}`);
835
727
  }
836
- return styles;
728
+ // 모든 슬라이드에 대해 VIEWABLE 이벤트 추적 (첫 번째 포함)
729
+ trackEventCallback(advertisement._id, slot.id, AdEventType.VIEWABLE);
837
730
  }
838
731
  /**
839
- * 기본 폰트 스타일
732
+ * 초기 슬라이드 로딩 시 VIEWABLE 이벤트 추적
733
+ * @param advertisement 첫 번째 슬라이드의 광고
734
+ * @param slot 광고 슬롯
735
+ * @param trackEventCallback 이벤트 추적 콜백
736
+ * @param debug 디버그 모드
840
737
  */
841
- getBaseFontStyles() {
842
- return {
843
- 'font-family': 'Arial, sans-serif',
844
- 'line-height': '1.4',
845
- 'word-break': 'keep-all',
846
- };
847
- }
848
- /**
849
- * 이미지 요소 생성 (SSR 안전)
850
- */
851
- createImageElement(imageUrl, alt = '', slot) {
852
- const img = DOMUtils.safeCreateElement('img');
853
- if (!img)
854
- return null;
855
- img.src = imageUrl;
856
- img.alt = alt;
857
- this.applyStyles(img, this.getImageStyles(slot));
858
- return img;
859
- }
860
- /**
861
- * 텍스트 요소 생성 (SSR 안전)
862
- */
863
- createTextElement(text, tag = 'div', additionalStyles = {}) {
864
- const element = DOMUtils.safeCreateElement(tag);
865
- if (!element)
866
- return null;
867
- DOMUtils.safeSetTextContent(element, text);
868
- this.applyStyles(element, {
869
- ...this.getBaseFontStyles(),
870
- ...additionalStyles,
871
- });
872
- return element;
873
- }
874
- /**
875
- * 플레이스홀더 요소 생성
876
- */
877
- createPlaceholder(slot, text = '광고') {
878
- let placeholder = DOMUtils.safeCreateElement('div');
879
- // SSR 환경에서 DOM을 사용할 수 없는 경우, 런타임에 생성되도록 함
880
- if (!placeholder) {
881
- // SSR에서는 빈 div를 반환하되, 브라우저에서는 제대로 작동하도록 함
882
- if (typeof document !== 'undefined') {
883
- placeholder = document.createElement('div');
884
- }
885
- else {
886
- // SSR 환경에서는 더미 객체 반환 (타입 단언 사용)
887
- placeholder = {};
888
- }
889
- }
890
- // DOM이 사용 가능한 경우에만 스타일 적용
891
- if (DOMUtils.canUseDOM() && placeholder) {
892
- this.applyStyles(placeholder, {
893
- ...this.getBaseContainerStyles(slot),
894
- background: '#f8f9fa',
895
- display: 'flex',
896
- 'align-items': 'center',
897
- 'justify-content': 'center',
898
- color: '#6c757d',
899
- ...this.getBaseFontStyles(),
900
- // 플레이스홀더는 최소 크기 보장
901
- 'min-width': '100px',
902
- 'min-height': '100px',
903
- });
904
- DOMUtils.safeSetTextContent(placeholder, text);
905
- }
906
- return placeholder;
907
- }
908
- }
909
-
910
- /**
911
- * 배너 광고 렌더러 - 이미지만 표시
912
- */
913
- class BannerAdRenderer extends BaseAdRenderer {
914
- render(ad, slot) {
915
- const adElement = DOMUtils.safeCreateElement('div');
916
- if (!adElement) {
917
- // SSR 환경에서는 기본 div 반환
918
- return document.createElement('div');
919
- }
920
- // 기본 컨테이너 스타일 적용 (불필요한 스타일 제거)
921
- this.applyStyles(adElement, this.getBaseContainerStyles(slot));
922
- // 배너 광고는 이미지만 표시
923
- if (!ad.imageUrl) {
924
- // 이미지가 없는 경우 플레이스홀더 반환
925
- const placeholder = this.createPlaceholder(slot, '배너 광고');
926
- return placeholder || adElement;
927
- }
928
- const img = this.createImageElement(ad.imageUrl, '', slot);
929
- if (img) {
930
- DOMUtils.safeAppendChild(adElement, img);
931
- // 클릭 이벤트 추가
932
- this.addClickHandler(adElement, ad, slot);
933
- }
934
- return adElement;
935
- }
936
- }
937
-
938
- /**
939
- * 텍스트 광고 렌더러 - textContent만 표시
940
- */
941
- class TextAdRenderer extends BaseAdRenderer {
942
- render(ad, slot) {
943
- let adElement = DOMUtils.safeCreateElement('div');
944
- if (!adElement) {
945
- return this.createPlaceholder(slot, '텍스트 광고');
946
- }
947
- // 기본 컨테이너 스타일
948
- const containerStyles = {
949
- ...this.getBaseContainerStyles(slot),
950
- padding: '4px',
951
- background: 'transparent',
952
- display: 'flex',
953
- 'align-items': 'center',
954
- 'justify-content': 'center',
955
- // text-align은 사용자가 설정할 수 있도록 기본값에서 제외
956
- ...this.getBaseFontStyles(),
957
- };
958
- // 사용자가 크기를 지정하지 않은 경우 컨텐츠에 맞춤
959
- if (!slot.width || slot.width === 0) {
960
- containerStyles.display = 'inline-flex';
961
- containerStyles['white-space'] = 'nowrap';
962
- containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
963
- }
964
- // height만 자동인 경우 줄바꿈 허용
965
- if ((slot.width && slot.width !== 0) && (!slot.height || slot.height === 0)) {
966
- containerStyles['white-space'] = 'normal';
967
- containerStyles['min-height'] = 'auto';
968
- containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
969
- }
970
- this.applyStyles(adElement, containerStyles);
971
- // 텍스트 광고는 textContent만 표시
972
- if (!ad.textContent) {
973
- // 텍스트가 없는 경우 플레이스홀더 반환
974
- return this.createPlaceholder(slot, '텍스트 광고');
975
- }
976
- // 텍스트 콘텐츠 생성
977
- const textContent = this.createTextElement(ad.textContent, 'div', {
978
- 'font-size': '14px',
979
- 'font-weight': '500',
980
- color: '#212529',
981
- width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
982
- });
983
- if (textContent) {
984
- adElement.appendChild(textContent);
985
- }
986
- // 사용자가 text-align을 지정했는지 확인하고 레이아웃 조정
987
- setTimeout(() => {
988
- if (!adElement || typeof window === 'undefined')
989
- return;
990
- const computedStyle = window.getComputedStyle(adElement);
991
- const textAlign = computedStyle.textAlign;
992
- // 사용자가 text-align을 설정했고, width가 없는 경우
993
- if (textAlign && textAlign !== 'start' && textAlign !== 'left' && (!slot.width || slot.width === 0)) {
994
- // 블록 레벨로 변경하여 text-align이 제대로 작동하도록 함
995
- adElement.style.display = 'block';
996
- adElement.style.whiteSpace = 'normal';
997
- // 최소 너비 설정 (텍스트가 한 줄일 때를 위해)
998
- if (textContent) {
999
- const textRect = textContent.getBoundingClientRect();
1000
- if (textRect.width > 0) {
1001
- adElement.style.minWidth = `${textRect.width}px`;
1002
- }
1003
- }
1004
- }
1005
- }, 0);
1006
- // 클릭 이벤트 추가
1007
- this.addClickHandler(adElement, ad, slot);
1008
- return adElement;
1009
- }
1010
- }
1011
-
1012
- /**
1013
- * 네이티브 광고 렌더러 - 이미지 + textContent 표시
1014
- */
1015
- class NativeAdRenderer extends BaseAdRenderer {
1016
- render(ad, slot) {
1017
- const adElement = DOMUtils.safeCreateElement('div');
1018
- if (!adElement) {
1019
- return document.createElement('div');
1020
- }
1021
- // 컨테이너 스타일 적용 (불필요한 스타일 제거)
1022
- this.applyStyles(adElement, this.getBaseContainerStyles(slot));
1023
- // 네이티브 광고는 이미지만 표시
1024
- if (!ad.imageUrl) {
1025
- // 이미지가 없는 경우 플레이스홀더 반환
1026
- const placeholder = this.createPlaceholder(slot, '네이티브 광고');
1027
- return placeholder || adElement;
1028
- }
1029
- // 이미지 생성 (고유 사이즈 또는 사용자 지정 크기)
1030
- const img = this.createImageElement(ad.imageUrl, '', slot);
1031
- if (img) {
1032
- DOMUtils.safeAppendChild(adElement, img);
1033
- // 클릭 이벤트 추가
1034
- this.addClickHandler(adElement, ad, slot);
738
+ static trackInitialSlideViewable(advertisement, slot, trackEventCallback, debug = false) {
739
+ if (debug) {
740
+ console.log(`🎯 Triggering initial VIEWABLE event: ad ${advertisement._id} (index: 0) in slot: ${slot.id}`);
1035
741
  }
1036
- return adElement;
742
+ // 첫 번째 슬라이드도 동일하게 추적
743
+ trackEventCallback(advertisement._id, slot.id, AdEventType.VIEWABLE);
1037
744
  }
1038
745
  }
1039
746
 
1040
747
  /**
1041
- * 비디오 광고 렌더러 - 비디오 또는 이미지 표시
748
+ * 광고 클릭 이벤트 핸들러 유틸리티
749
+ * 모든 광고 타입에서 일관된 클릭 이벤트 처리를 위한 공통 컴포넌트
1042
750
  */
1043
- class VideoAdRenderer extends BaseAdRenderer {
1044
- render(ad, slot) {
1045
- let adElement = DOMUtils.safeCreateElement('div');
1046
- if (!adElement) {
1047
- return this.createPlaceholder(slot, '비디오 광고');
1048
- }
1049
- // 컨테이너 스타일 적용 (불필요한 스타일 제거)
1050
- this.applyStyles(adElement, {
1051
- ...this.getBaseContainerStyles(slot),
1052
- background: '#000',
1053
- });
1054
- // 비디오 광고는 비디오만 표시
1055
- if (!ad.videoUrl) {
1056
- // 비디오가 없는 경우 플레이스홀더 반환
1057
- return this.createPlaceholder(slot, '비디오 광고');
1058
- }
1059
- const video = this.createVideoElement(ad.videoUrl, ad, slot);
1060
- if (video) {
1061
- adElement.appendChild(video);
1062
- }
1063
- // 클릭 이벤트 추가
1064
- this.addClickHandler(adElement, ad, slot);
1065
- return adElement;
1066
- }
751
+ class AdClickHandler {
1067
752
  /**
1068
- * 비디오 요소 생성
753
+ * 광고 요소에 클릭 이벤트를 추가하는 공통 함수
754
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
755
+ * @param advertisement - 광고 데이터
756
+ * @param slot - 광고 슬롯 정보
757
+ * @param trackEventCallback - 이벤트 추적 콜백 함수
758
+ * @param debug - 디버그 모드
759
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
1069
760
  */
1070
- createVideoElement(videoUrl, ad, slot) {
1071
- const video = DOMUtils.safeCreateElement('video');
1072
- if (!video)
1073
- return null;
1074
- video.src = videoUrl;
1075
- video.controls = true;
1076
- // 비디오도 이미지와 같은 스타일 적용
1077
- this.applyStyles(video, this.getImageStyles(slot));
1078
- // 비디오 이벤트 추적
1079
- video.addEventListener('play', () => {
1080
- this.trackEvent?.(ad._id, slot.id, 'VIDEO_START');
1081
- });
1082
- video.addEventListener('ended', () => {
1083
- this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
1084
- });
1085
- return video;
1086
- }
1087
- }
1088
-
1089
- /**
1090
- * 전면/팝업 광고 렌더러 - 핵심 콘텐츠만 표시
1091
- */
1092
- class InterstitialAdRenderer extends BaseAdRenderer {
1093
- render(ad, slot) {
1094
- let adElement = DOMUtils.safeCreateElement('div');
1095
- if (!adElement) {
1096
- return this.createPlaceholder(slot, '전면 광고');
1097
- }
1098
- // 컨테이너 스타일 적용 (불필요한 스타일 제거)
1099
- this.applyStyles(adElement, {
1100
- ...this.getBaseContainerStyles(slot),
1101
- display: 'flex',
1102
- 'flex-direction': 'column',
1103
- });
1104
- // 우선순위: 1. 이미지, 2. 비디오, 3. 텍스트
1105
- if (ad.imageUrl) {
1106
- const img = this.createImageElement(ad.imageUrl, '', slot);
1107
- if (img) {
1108
- adElement.appendChild(img);
1109
- }
761
+ static addClickEvent(element, advertisement, slot, trackEventCallback, debug = false, adTypeLabel) {
762
+ // linkUrl이 없으면 클릭 이벤트 추가하지 않음
763
+ if (!advertisement.linkUrl) {
764
+ return;
1110
765
  }
1111
- else if (ad.videoUrl) {
1112
- // 이미지가 없고 비디오가 있는 경우
1113
- const video = this.createVideoElement(ad.videoUrl, ad, slot);
1114
- if (video) {
1115
- adElement.appendChild(video);
766
+ // 커서 스타일 설정
767
+ element.style.cursor = 'pointer';
768
+ // 클릭 이벤트 리스너 추가
769
+ element.addEventListener('click', (e) => {
770
+ e.preventDefault();
771
+ e.stopPropagation();
772
+ // 이벤트 추적
773
+ if (trackEventCallback) {
774
+ trackEventCallback(advertisement._id, slot.id, AdEventType.CLICK);
775
+ }
776
+ // 링크 이동
777
+ window.open(advertisement.linkUrl, '_blank');
778
+ // 디버그 로그
779
+ if (debug) {
780
+ const typeLabel = adTypeLabel || String(slot.adType).toLowerCase();
781
+ console.log(`🔗 ${typeLabel} ad clicked: ${advertisement._id} -> ${advertisement.linkUrl}`);
1116
782
  }
1117
- }
1118
- else {
1119
- // 모든 콘텐츠가 없는 경우
1120
- return this.createPlaceholder(slot, '전면 광고');
1121
- }
1122
- // 클릭 이벤트 추가
1123
- this.addClickHandler(adElement, ad, slot);
1124
- return adElement;
1125
- }
1126
- /**
1127
- * 비디오 요소 생성
1128
- */
1129
- createVideoElement(videoUrl, ad, slot) {
1130
- const video = DOMUtils.safeCreateElement('video');
1131
- if (!video)
1132
- return null;
1133
- video.src = videoUrl;
1134
- video.controls = true;
1135
- // 비디오도 이미지와 같은 스타일 적용
1136
- this.applyStyles(video, this.getImageStyles(slot));
1137
- // 비디오 이벤트 추적
1138
- video.addEventListener('play', () => {
1139
- this.trackEvent?.(ad._id, slot.id, 'VIDEO_START');
1140
- });
1141
- video.addEventListener('ended', () => {
1142
- this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
1143
783
  });
1144
- return video;
1145
784
  }
1146
- }
1147
-
1148
- var _a;
1149
- /**
1150
- * 광고 렌더러 팩토리
1151
- * - 광고 타입에 따라 적절한 렌더러 인스턴스를 반환
1152
- */
1153
- class AdRendererFactory {
1154
785
  /**
1155
- * 광고 타입에 맞는 렌더러 생성
786
+ * 렌더러에서 사용할 있는 간편한 클릭 이벤트 추가 함수
787
+ * BaseAdRenderer를 상속받은 클래스에서 사용
788
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
789
+ * @param advertisement - 광고 데이터
790
+ * @param slot - 광고 슬롯 정보
791
+ * @param createEventTrackingCallback - 이벤트 추적 콜백 생성 함수
792
+ * @param debug - 디버그 모드
793
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
1156
794
  */
1157
- static createRenderer(adType, trackEvent) {
1158
- const RendererClass = this.renderers.get(adType);
1159
- if (!RendererClass) {
1160
- console.warn(`No renderer found for ad type: ${adType}, falling back to Banner renderer`);
1161
- return new BannerAdRenderer(trackEvent);
795
+ static addClickEventForRenderer(element, advertisement, slot, createEventTrackingCallback, debug = false, adTypeLabel) {
796
+ // linkUrl이 없으면 클릭 이벤트 추가하지 않음
797
+ if (!advertisement.linkUrl) {
798
+ return;
1162
799
  }
1163
- return new RendererClass(trackEvent);
800
+ // 커서 스타일 설정
801
+ element.style.cursor = 'pointer';
802
+ // 클릭 이벤트 리스너 추가
803
+ element.addEventListener('click', (e) => {
804
+ e.preventDefault();
805
+ e.stopPropagation();
806
+ // 이벤트 추적 콜백 생성
807
+ const trackEventCallback = createEventTrackingCallback();
808
+ trackEventCallback(advertisement._id, slot.id, AdEventType.CLICK);
809
+ // 링크 이동
810
+ window.open(advertisement.linkUrl, '_blank');
811
+ // 디버그 로그
812
+ if (debug) {
813
+ const typeLabel = adTypeLabel || String(slot.adType).toLowerCase();
814
+ console.log(`🔗 ${typeLabel} ad clicked: ${advertisement._id} -> ${advertisement.linkUrl}`);
815
+ }
816
+ });
1164
817
  }
1165
818
  /**
1166
- * 광고 렌더링 (편의 메서드)
819
+ * 슬라이더/매니저에서 사용할 있는 클릭 이벤트 추가 함수
820
+ * 이미 trackEventCallback이 준비된 상황에서 사용
821
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
822
+ * @param advertisement - 광고 데이터
823
+ * @param slot - 광고 슬롯 정보
824
+ * @param trackEventCallback - 준비된 이벤트 추적 콜백
825
+ * @param debug - 디버그 모드
826
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
1167
827
  */
1168
- static render(ad, slot, trackEvent) {
1169
- const renderer = this.createRenderer(slot.adType, trackEvent);
1170
- return renderer.render(ad, slot);
828
+ static addClickEventForSlider(element, advertisement, slot, trackEventCallback, debug = false, adTypeLabel) {
829
+ this.addClickEvent(element, advertisement, slot, trackEventCallback, debug, adTypeLabel);
1171
830
  }
1172
831
  /**
1173
- * 사용 가능한 렌더러 타입 목록
832
+ * 클릭 가능한 광고인지 확인하는 헬퍼 함수
833
+ * @param advertisement - 광고 데이터
834
+ * @returns linkUrl이 있으면 true, 없으면 false
1174
835
  */
1175
- static getSupportedAdTypes() {
1176
- return Array.from(this.renderers.keys());
836
+ static isClickable(advertisement) {
837
+ return Boolean(advertisement.linkUrl);
1177
838
  }
1178
839
  /**
1179
- * 커스텀 렌더러 등록
840
+ * 여러 요소에 대해 일괄적으로 클릭 이벤트를 추가하는 함수
841
+ * @param elements - 클릭 이벤트를 추가할 DOM 요소들
842
+ * @param advertisements - 광고 데이터 배열 (elements와 같은 순서)
843
+ * @param slot - 광고 슬롯 정보
844
+ * @param trackEventCallback - 이벤트 추적 콜백
845
+ * @param debug - 디버그 모드
846
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
1180
847
  */
1181
- static registerRenderer(adType, RendererClass) {
1182
- this.renderers.set(adType, RendererClass);
848
+ static addClickEventsBatch(elements, advertisements, slot, trackEventCallback, debug = false, adTypeLabel) {
849
+ elements.forEach((element, index) => {
850
+ const advertisement = advertisements[index];
851
+ if (advertisement) {
852
+ this.addClickEvent(element, advertisement, slot, trackEventCallback, debug, adTypeLabel);
853
+ }
854
+ });
1183
855
  }
1184
856
  }
1185
- _a = AdRendererFactory;
1186
- AdRendererFactory.renderers = new Map();
1187
- (() => {
1188
- // 렌더러 등록
1189
- _a.renderers.set(AdType.BANNER, BannerAdRenderer);
1190
- _a.renderers.set(AdType.TEXT, TextAdRenderer);
1191
- _a.renderers.set(AdType.NATIVE, NativeAdRenderer);
1192
- _a.renderers.set(AdType.VIDEO, VideoAdRenderer);
1193
- _a.renderers.set(AdType.INTERSTITIAL, InterstitialAdRenderer);
1194
- _a.renderers.set(AdType.POPUP, InterstitialAdRenderer); // POPUP은 INTERSTITIAL과 동일
1195
- })();
1196
857
 
1197
858
  /**
1198
859
  * 캐러셀 슬라이더 관리 클래스
@@ -1202,10 +863,78 @@ AdRendererFactory.renderers = new Map();
1202
863
  * - 도트 인디케이터 포함
1203
864
  */
1204
865
  class CarouselSliderManager {
866
+ /**
867
+ * 간단한 광고 요소 생성 (크기 측정용)
868
+ */
869
+ static createSimpleAdElement(slot, advertisement) {
870
+ const adElement = document.createElement('div');
871
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
872
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
873
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
874
+ // 기본 스타일 설정
875
+ adElement.style.display = 'block';
876
+ adElement.style.width = '100%';
877
+ adElement.style.height = 'auto';
878
+ // 광고 타입별 기본 컨테이너 설정
879
+ switch (slot.adType) {
880
+ case AdType.BANNER:
881
+ if (advertisement.imageUrl) {
882
+ const img = document.createElement('img');
883
+ img.src = advertisement.imageUrl;
884
+ img.style.width = '100%';
885
+ img.style.height = 'auto';
886
+ img.style.objectFit = 'cover';
887
+ adElement.appendChild(img);
888
+ }
889
+ else {
890
+ adElement.style.height = '100px';
891
+ adElement.style.backgroundColor = '#f0f0f0';
892
+ adElement.style.border = '1px dashed #ccc';
893
+ adElement.textContent = 'Banner Ad';
894
+ }
895
+ break;
896
+ case AdType.VIDEO:
897
+ if (advertisement.videoUrl) {
898
+ const video = document.createElement('video');
899
+ video.src = advertisement.videoUrl;
900
+ video.style.width = '100%';
901
+ video.style.height = 'auto';
902
+ adElement.appendChild(video);
903
+ }
904
+ else {
905
+ adElement.style.height = '200px';
906
+ adElement.style.backgroundColor = '#000';
907
+ adElement.style.border = '1px solid #666';
908
+ adElement.textContent = 'Video Ad';
909
+ adElement.style.color = 'white';
910
+ }
911
+ break;
912
+ case AdType.TEXT:
913
+ if (advertisement.textContent) {
914
+ const textDiv = document.createElement('div');
915
+ textDiv.textContent = advertisement.textContent || '';
916
+ textDiv.style.padding = '8px';
917
+ textDiv.style.fontSize = '14px';
918
+ adElement.appendChild(textDiv);
919
+ }
920
+ else {
921
+ adElement.style.height = '50px';
922
+ adElement.style.padding = '8px';
923
+ adElement.textContent = 'Text Ad';
924
+ }
925
+ break;
926
+ default:
927
+ adElement.style.height = '100px';
928
+ adElement.style.border = '1px dashed #ccc';
929
+ adElement.style.backgroundColor = '#f9f9f9';
930
+ adElement.textContent = `${slot.adType} Ad`;
931
+ }
932
+ return adElement;
933
+ }
1205
934
  /**
1206
935
  * Create carousel slider container with dot indicators and navigation
1207
936
  */
1208
- static createSliderContainer(slot, advertisements, options, trackEventCallback) {
937
+ static createSliderContainer(slot, advertisements, options, trackEventCallback, debug = false) {
1209
938
  const sliderWrapper = document.createElement('div');
1210
939
  sliderWrapper.className = 'adstage-slider-wrapper';
1211
940
  // 사용자 지정 크기가 있으면 적용, 없으면 콘텐츠 크기에 맞춤
@@ -1268,7 +997,7 @@ class CarouselSliderManager {
1268
997
  let maxHeight = 0;
1269
998
  // 모든 광고의 크기를 측정하여 최대 크기 찾기
1270
999
  advertisements.forEach(ad => {
1271
- const measureAdElement = AdRendererFactory.render(ad, slot, trackEventCallback);
1000
+ const measureAdElement = this.createSimpleAdElement(slot, ad);
1272
1001
  measureContainer.appendChild(measureAdElement);
1273
1002
  const rect = measureAdElement.getBoundingClientRect();
1274
1003
  if (rect.width > maxWidth)
@@ -1326,7 +1055,9 @@ class CarouselSliderManager {
1326
1055
  slideElement.style.setProperty(key, value);
1327
1056
  });
1328
1057
  // 광고 렌더링
1329
- const adElement = AdRendererFactory.render(ad, slot, trackEventCallback);
1058
+ const adElement = this.createSimpleAdElement(slot, ad);
1059
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1060
+ AdClickHandler.addClickEventForSlider(adElement, ad, slot, trackEventCallback, debug, String(slot.adType).toLowerCase());
1330
1061
  slideElement.appendChild(adElement);
1331
1062
  slideContainer.appendChild(slideElement);
1332
1063
  });
@@ -1370,9 +1101,9 @@ class CarouselSliderManager {
1370
1101
  }
1371
1102
  });
1372
1103
  }
1373
- // 현재 슬라이드의 광고에 대해 노출 이벤트 추적 (모든 슬라이드 포함)
1374
- console.log(`🎯 Triggering VIEWABLE event for slide change: ad ${advertisements[actualIndex]._id} (index: ${actualIndex}) in slot: ${slot.id}`);
1375
- trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.VIEWABLE);
1104
+ // 🎯 공통 슬라이더 이벤트 추적 적용 (모든 슬라이드 포함)
1105
+ SliderEventTracker.trackSlideViewable(advertisements[actualIndex], slot, actualIndex, trackEventCallback, debug // debug 모드
1106
+ );
1376
1107
  };
1377
1108
  // 무한 루프 처리 함수
1378
1109
  const handleInfiniteLoop = () => {
@@ -1414,13 +1145,8 @@ class CarouselSliderManager {
1414
1145
  if (dotContainer) {
1415
1146
  sliderWrapper.appendChild(dotContainer);
1416
1147
  }
1417
- // 첫 번째 도트 활성화 초기 VIEWABLE 이벤트 발생
1148
+ // 첫 번째 도트 활성화 (moveToSlide에서 자동으로 VIEWABLE 이벤트 발생)
1418
1149
  moveToSlide(0);
1419
- // 슬라이더 DOM 추가 후 첫 번째 광고 VIEWABLE 이벤트 트리거 (100ms 후 실제 노출 확인)
1420
- setTimeout(() => {
1421
- console.log(`🎯 Triggering initial VIEWABLE event for first ad: ${advertisements[0]._id} in slot: ${slot.id}`);
1422
- trackEventCallback(advertisements[0]._id, slot.id, AdEventType.VIEWABLE);
1423
- }, 100);
1424
1150
  // 사용자가 크기를 지정하지 않은 경우, 첫 번째 슬라이드 크기에 맞춰 래퍼 크기 동적 조정
1425
1151
  if (!slot.width || slot.width === 0) {
1426
1152
  // DOM 렌더링 후 크기 측정
@@ -1537,47 +1263,749 @@ class CarouselSliderManager {
1537
1263
  }
1538
1264
 
1539
1265
  /**
1540
- * 텍스트 전환 효과 관리 클래스
1541
- * - 텍스트 광고 전용 페이드 인/아웃 + 상하 움직임 효과
1542
- * - 부드러운 전환 애니메이션 (vertical transition)
1543
- * - 무한 루프 지원
1266
+ * 단순한 VIEWABLE 이벤트 중복 방지 관리 클래스
1267
+ * - 세션당 동일 광고 1회만 VIEWABLE 이벤트 허용
1268
+ * - 메모리 기반 추적으로 단순화
1544
1269
  */
1545
- class TextTransitionManager {
1270
+ class ViewableEventTracker {
1546
1271
  /**
1547
- * 텍스트 전환 슬라이더 컨테이너 생성
1272
+ * 중복 viewable 이벤트 여부 확인
1548
1273
  */
1549
- static createTextTransitionContainer(slot, advertisements, options, trackEventCallback) {
1550
- const sliderWrapper = document.createElement('div');
1551
- sliderWrapper.className = 'adstage-fade-slider-wrapper';
1552
- // 래퍼 스타일 설정
1553
- const containerStyles = {
1554
- position: 'relative',
1555
- overflow: 'hidden',
1556
- display: 'inline-block',
1557
- };
1558
- // 사용자가 크기를 지정한 경우
1559
- if (slot.width && slot.width !== 0) {
1560
- let width;
1561
- if (typeof slot.width === 'string') {
1562
- width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
1563
- }
1564
- else {
1565
- width = `${slot.width}px`;
1274
+ static isDuplicateViewable(adId, slotId, debug = false) {
1275
+ const key = `${adId}_${slotId}`;
1276
+ // 이미 VIEWABLE 이벤트가 발생한 광고인지 확인
1277
+ if (ViewableEventTracker.viewableTracker.has(key)) {
1278
+ if (debug) {
1279
+ console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
1566
1280
  }
1567
- containerStyles.width = width;
1281
+ return true;
1568
1282
  }
1569
- if (slot.height && slot.height !== 0) {
1570
- let height;
1571
- if (typeof slot.height === 'string') {
1572
- height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
1573
- }
1574
- else {
1575
- height = `${slot.height}px`;
1576
- }
1577
- containerStyles.height = height;
1283
+ // 새로운 VIEWABLE 이벤트 기록
1284
+ ViewableEventTracker.viewableTracker.add(key);
1285
+ if (debug) {
1286
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
1578
1287
  }
1579
- // 스타일 적용
1580
- Object.entries(containerStyles).forEach(([key, value]) => {
1288
+ return false;
1289
+ }
1290
+ /**
1291
+ * 모든 추적 데이터 정리 (디버그용)
1292
+ */
1293
+ static clear() {
1294
+ ViewableEventTracker.viewableTracker.clear();
1295
+ }
1296
+ /**
1297
+ * 특정 광고의 viewable 추적 초기화 (디버그용)
1298
+ */
1299
+ static clearAdViewable(adId, slotId) {
1300
+ const key = `${adId}_${slotId}`;
1301
+ ViewableEventTracker.viewableTracker.delete(key);
1302
+ }
1303
+ }
1304
+ ViewableEventTracker.viewableTracker = new Set();
1305
+
1306
+ /**
1307
+ * 베이스 광고 렌더러 - 공통 기능 구현
1308
+ */
1309
+ class BaseAdRenderer {
1310
+ constructor(adType, debug = false, advertisementEventTracker) {
1311
+ this.adType = adType;
1312
+ this.debug = debug;
1313
+ this.advertisementEventTracker = advertisementEventTracker || null;
1314
+ }
1315
+ /**
1316
+ * Placeholder(슬롯 컨테이너) 생성 - 공통 구현
1317
+ */
1318
+ createPlaceholder(container, slotId, options, config) {
1319
+ const adElement = document.createElement('div');
1320
+ adElement.id = slotId;
1321
+ adElement.className = `adstage-slot adstage-${String(this.adType).toLowerCase()}`;
1322
+ adElement.setAttribute('data-adstage-container', 'true');
1323
+ adElement.setAttribute('data-adstage-type', String(this.adType));
1324
+ adElement.setAttribute('data-adstage-slot', slotId);
1325
+ const { width, height } = this.calculateAdSize(container, options, config) || {
1326
+ width: '100%',
1327
+ height: this.getDefaultHeight()
1328
+ };
1329
+ adElement.style.width = width;
1330
+ adElement.style.height = height;
1331
+ // 플레이스홀더 스타일 모드 결정
1332
+ const placeholderMode = config?.placeholderMode || options.placeholderMode || 'invisible';
1333
+ this.applyPlaceholderStyle(adElement, placeholderMode);
1334
+ container.appendChild(adElement);
1335
+ if (this.debug) {
1336
+ console.log(`📦 Placeholder created for ${this.adType} slot: ${slotId} (${width} x ${height}) - Mode: ${placeholderMode}`);
1337
+ }
1338
+ }
1339
+ /**
1340
+ * 플레이스홀더 스타일 적용
1341
+ */
1342
+ applyPlaceholderStyle(element, mode) {
1343
+ switch (mode) {
1344
+ case 'invisible':
1345
+ // 완전히 투명한 플레이스홀더
1346
+ element.style.backgroundColor = 'transparent';
1347
+ element.style.border = 'none';
1348
+ element.style.opacity = '0';
1349
+ element.innerHTML = '';
1350
+ break;
1351
+ case 'transparent':
1352
+ // 투명하지만 공간은 차지
1353
+ element.style.backgroundColor = 'transparent';
1354
+ element.style.border = 'none';
1355
+ element.style.display = 'block';
1356
+ element.innerHTML = '';
1357
+ break;
1358
+ case 'subtle':
1359
+ // 매우 은은한 표시
1360
+ element.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
1361
+ element.style.border = 'none';
1362
+ element.style.borderRadius = '4px';
1363
+ element.style.display = 'flex';
1364
+ element.style.alignItems = 'center';
1365
+ element.style.justifyContent = 'center';
1366
+ element.innerHTML = '<span style="color: rgba(0, 0, 0, 0.3); font-size: 11px; font-family: sans-serif;">•••</span>';
1367
+ break;
1368
+ case 'minimal':
1369
+ // 최소한의 표시 (기본값)
1370
+ element.style.backgroundColor = 'rgba(248, 249, 250, 0.5)';
1371
+ element.style.border = '1px solid rgba(0, 0, 0, 0.08)';
1372
+ element.style.borderRadius = '6px';
1373
+ element.style.display = 'flex';
1374
+ element.style.alignItems = 'center';
1375
+ element.style.justifyContent = 'center';
1376
+ element.innerHTML = '<span style="color: rgba(0, 0, 0, 0.4); font-size: 12px; font-family: -apple-system, sans-serif;">•••</span>';
1377
+ break;
1378
+ case 'debug':
1379
+ // 개발/디버그용 명확한 표시
1380
+ element.style.border = '2px dashed #e74c3c';
1381
+ element.style.display = 'flex';
1382
+ element.style.alignItems = 'center';
1383
+ element.style.justifyContent = 'center';
1384
+ element.style.backgroundColor = 'rgba(231, 76, 60, 0.1)';
1385
+ element.style.color = '#e74c3c';
1386
+ element.style.fontFamily = 'monospace';
1387
+ element.style.fontSize = '11px';
1388
+ element.innerHTML = `<span>Loading ${this.adType} ad...</span>`;
1389
+ break;
1390
+ default:
1391
+ // 기존 스타일 (legacy)
1392
+ element.style.border = '1px dashed #ccc';
1393
+ element.style.display = 'flex';
1394
+ element.style.alignItems = 'center';
1395
+ element.style.justifyContent = 'center';
1396
+ element.style.backgroundColor = '#f9f9f9';
1397
+ element.style.color = '#666';
1398
+ element.innerHTML = `<span>Loading ${this.adType} ad...</span>`;
1399
+ }
1400
+ }
1401
+ /**
1402
+ * 광고 크기 계산 - 공통 구현
1403
+ */
1404
+ calculateAdSize(container, options, config) {
1405
+ // 사용자가 명시적으로 크기를 지정한 경우
1406
+ const explicitWidth = options.width;
1407
+ const explicitHeight = options.height;
1408
+ // 너비 처리
1409
+ let width;
1410
+ if (typeof explicitWidth === 'number') {
1411
+ width = `${explicitWidth}px`;
1412
+ }
1413
+ else if (typeof explicitWidth === 'string') {
1414
+ width = explicitWidth;
1415
+ }
1416
+ else {
1417
+ width = '100%'; // 기본값은 100%
1418
+ }
1419
+ // 높이 처리 - 핵심 로직
1420
+ let height;
1421
+ if (typeof explicitHeight === 'number') {
1422
+ height = `${explicitHeight}px`;
1423
+ }
1424
+ else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
1425
+ // 명시적인 크기 문자열 (예: '200px', '50vh' 등)
1426
+ height = explicitHeight;
1427
+ }
1428
+ else {
1429
+ // 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
1430
+ const containerHeight = this.getContainerHeight(container);
1431
+ if (containerHeight > 0) {
1432
+ // 컨테이너에 높이가 있으면 100% 사용
1433
+ height = '100%';
1434
+ if (config?.debug || this.debug) {
1435
+ console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
1436
+ }
1437
+ }
1438
+ else {
1439
+ // 컨테이너에 높이가 없으면 타입별 기본값 사용
1440
+ height = this.getDefaultHeight();
1441
+ if (config?.debug || this.debug) {
1442
+ console.log(`📏 Using default height ${height} for ${this.adType}`);
1443
+ }
1444
+ }
1445
+ }
1446
+ return { width, height };
1447
+ }
1448
+ /**
1449
+ * 컨테이너의 실제 높이 계산 - 공통 구현
1450
+ */
1451
+ getContainerHeight(container) {
1452
+ const computedStyle = window.getComputedStyle(container);
1453
+ const height = parseFloat(computedStyle.height);
1454
+ if (!height || height === 0) {
1455
+ const minHeight = parseFloat(computedStyle.minHeight);
1456
+ if (minHeight > 0)
1457
+ return minHeight;
1458
+ if (container.style.height && container.style.height !== 'auto') {
1459
+ const styleHeight = parseFloat(container.style.height);
1460
+ if (styleHeight > 0)
1461
+ return styleHeight;
1462
+ }
1463
+ const heightAttr = container.getAttribute('height');
1464
+ if (heightAttr) {
1465
+ const attrHeight = parseFloat(heightAttr);
1466
+ if (attrHeight > 0)
1467
+ return attrHeight;
1468
+ }
1469
+ }
1470
+ return height || 0;
1471
+ }
1472
+ /**
1473
+ * 이벤트 트래킹 콜백 생성 - 공통 구현
1474
+ */
1475
+ createEventTrackingCallback() {
1476
+ return async (adId, slotId, eventType) => {
1477
+ if (eventType === AdEventType.VIEWABLE) {
1478
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
1479
+ if (this.debug) {
1480
+ console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
1481
+ }
1482
+ return;
1483
+ }
1484
+ if (this.debug) {
1485
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
1486
+ }
1487
+ }
1488
+ if (this.advertisementEventTracker) {
1489
+ try {
1490
+ if (this.debug) {
1491
+ console.log(`🔄 Starting advertisement event tracking: ${eventType} for ad ${adId} in slot ${slotId}`);
1492
+ }
1493
+ await this.advertisementEventTracker.trackAdvertisementEvent(adId, slotId, eventType);
1494
+ if (this.debug) {
1495
+ console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
1496
+ }
1497
+ }
1498
+ catch (error) {
1499
+ if (this.debug) {
1500
+ console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
1501
+ }
1502
+ }
1503
+ }
1504
+ else {
1505
+ if (this.debug) {
1506
+ console.warn(`⚠️ AdvertisementEventTracker not available for ${eventType} event`);
1507
+ }
1508
+ }
1509
+ };
1510
+ }
1511
+ /**
1512
+ * Fallback 광고 렌더링 - 공통 구현
1513
+ */
1514
+ renderFallback(slot) {
1515
+ const element = document.getElementById(slot.id);
1516
+ if (element) {
1517
+ const adstageContainers = [
1518
+ element.querySelector('[data-adstage-container="true"]'),
1519
+ element.closest('[data-adstage-container="true"]'),
1520
+ element
1521
+ ].filter(el => el && el.hasAttribute('data-adstage-container'));
1522
+ const classBasedContainers = [
1523
+ element.closest('.adstage-slot'),
1524
+ element.closest(`.adstage-${String(this.adType).toLowerCase()}`),
1525
+ element.closest('[class*="ad"]'),
1526
+ element.closest('[class*="banner"]'),
1527
+ element.closest('[class*="container"]'),
1528
+ element.closest('div[style*="height"]'),
1529
+ element.closest('div[style*="min-height"]'),
1530
+ element.parentElement
1531
+ ].filter(Boolean);
1532
+ const possibleContainers = [...adstageContainers, ...classBasedContainers];
1533
+ const targetContainer = possibleContainers[0];
1534
+ if (targetContainer) {
1535
+ let containerType = 'unknown';
1536
+ if (targetContainer.hasAttribute('data-adstage-container')) {
1537
+ containerType = 'adstage-official';
1538
+ }
1539
+ else if (targetContainer.classList.contains('adstage-slot')) {
1540
+ containerType = 'adstage-class';
1541
+ }
1542
+ else {
1543
+ containerType = 'generic';
1544
+ }
1545
+ targetContainer.style.cssText += `
1546
+ height: 0px !important;
1547
+ min-height: 0px !important;
1548
+ padding: 0px !important;
1549
+ margin: 0px !important;
1550
+ border: none !important;
1551
+ overflow: hidden !important;
1552
+ display: block !important;
1553
+ `;
1554
+ targetContainer.innerHTML = '';
1555
+ targetContainer.setAttribute('data-adstage-empty', 'true');
1556
+ if (this.debug) {
1557
+ console.warn(`⚠️ ${this.adType} container collapsed (${containerType}): ${slot.id}`, targetContainer);
1558
+ }
1559
+ }
1560
+ else {
1561
+ this.createEmptyContainer(slot);
1562
+ }
1563
+ }
1564
+ slot.advertisement = undefined;
1565
+ slot.isEmpty = true;
1566
+ }
1567
+ /**
1568
+ * 빈 컨테이너 생성 - 공통 구현
1569
+ */
1570
+ createEmptyContainer(slot) {
1571
+ const originalContainer = document.getElementById(slot.containerId);
1572
+ if (originalContainer) {
1573
+ originalContainer.innerHTML = '';
1574
+ const emptyElement = document.createElement('div');
1575
+ emptyElement.id = slot.id;
1576
+ emptyElement.className = `adstage-slot adstage-empty adstage-${String(this.adType).toLowerCase()}`;
1577
+ emptyElement.setAttribute('data-adstage-container', 'true');
1578
+ emptyElement.setAttribute('data-adstage-empty', 'true');
1579
+ emptyElement.setAttribute('data-adstage-slot', slot.id);
1580
+ emptyElement.style.cssText = `
1581
+ height: 0px !important;
1582
+ min-height: 0px !important;
1583
+ padding: 0px !important;
1584
+ margin: 0px !important;
1585
+ border: none !important;
1586
+ overflow: hidden !important;
1587
+ display: block !important;
1588
+ `;
1589
+ originalContainer.appendChild(emptyElement);
1590
+ if (this.debug) {
1591
+ console.warn(`⚠️ Created empty ${this.adType} container: ${slot.id}`);
1592
+ }
1593
+ }
1594
+ }
1595
+ /**
1596
+ * 디버그 로그 출력 - 공통 구현
1597
+ */
1598
+ log(message, ...args) {
1599
+ if (this.debug) {
1600
+ console.log(`[${this.adType}] ${message}`, ...args);
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ /**
1606
+ import { AdSlot, Advertisement, AdType } from '../../../types/advertisement';
1607
+ import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
1608
+ import { CarouselSliderManager } from '../../../managers/ads/carousel-slider-manager';
1609
+ import { BaseAdRenderer } from './base-ad-renderer';
1610
+ import { AdRenderOptions } from '../interfaces/i-ad-renderer'; 광고 전용 렌더러
1611
+ */
1612
+ class BannerAdRenderer extends BaseAdRenderer {
1613
+ constructor(debug = false, advertisementEventTracker) {
1614
+ super(AdType.BANNER, debug, advertisementEventTracker);
1615
+ }
1616
+ /**
1617
+ * 배너 광고 기본 높이
1618
+ */
1619
+ getDefaultHeight() {
1620
+ return '250px';
1621
+ }
1622
+ /**
1623
+ * 단일 배너 광고 렌더링
1624
+ */
1625
+ async renderAdElement(slot, advertisement) {
1626
+ const container = document.getElementById(slot.containerId);
1627
+ if (!container)
1628
+ return;
1629
+ const adElement = document.createElement('div');
1630
+ adElement.className = 'adstage-ad adstage-banner-ad';
1631
+ const optimizedHeight = slot.optimizedHeight;
1632
+ const containerElement = container.parentElement || container;
1633
+ if (optimizedHeight) {
1634
+ adElement.style.width = '100%';
1635
+ adElement.style.height = String(optimizedHeight);
1636
+ }
1637
+ else {
1638
+ const config = slot.config;
1639
+ const options = {
1640
+ width: config?.width,
1641
+ height: config?.height
1642
+ };
1643
+ const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
1644
+ adElement.style.width = width;
1645
+ adElement.style.height = height;
1646
+ }
1647
+ if (advertisement.imageUrl) {
1648
+ await this.renderOptimizedBannerImage(adElement, advertisement, slot);
1649
+ }
1650
+ else {
1651
+ adElement.innerHTML = `<div>${advertisement.title || 'Banner Ad'}</div>`;
1652
+ }
1653
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1654
+ AdClickHandler.addClickEventForRenderer(adElement, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1655
+ container.innerHTML = '';
1656
+ container.appendChild(adElement);
1657
+ }
1658
+ /**
1659
+ * 다중 배너 광고 렌더링 (슬라이더)
1660
+ */
1661
+ async renderMultipleAds(slot, advertisements) {
1662
+ const container = document.getElementById(slot.containerId);
1663
+ if (!container) {
1664
+ throw new Error(`Container not found: ${slot.containerId}`);
1665
+ }
1666
+ // 배너 광고를 위한 컨테이너 최적화
1667
+ await this.optimizeContainerForBannerAds(slot, advertisements);
1668
+ const trackEventCallback = this.createEventTrackingCallback();
1669
+ const optimizedSliderOptions = {
1670
+ autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
1671
+ ...slot.config,
1672
+ optimizedHeight: slot.optimizedHeight,
1673
+ aspectRatio: slot.aspectRatio
1674
+ };
1675
+ const sliderElement = CarouselSliderManager.createSliderContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback, this.debug);
1676
+ if (sliderElement) {
1677
+ container.innerHTML = '';
1678
+ container.appendChild(sliderElement);
1679
+ if (this.debug) {
1680
+ console.log(`🎠 Banner carousel created for slot: ${slot.id} with ${advertisements.length} ads (optimized: ${slot.optimizedHeight || 'default'})`);
1681
+ }
1682
+ }
1683
+ }
1684
+ /**
1685
+ * 여러 광고의 최적 컨테이너 크기 계산
1686
+ */
1687
+ async calculateOptimalContainerSize(advertisements, containerWidth) {
1688
+ if (!advertisements.length) {
1689
+ return {
1690
+ width: '100%',
1691
+ height: this.getDefaultHeight(),
1692
+ aspectRatio: 16 / 9
1693
+ };
1694
+ }
1695
+ try {
1696
+ const imageDimensions = await Promise.allSettled(advertisements
1697
+ .filter(ad => ad.imageUrl)
1698
+ .map(ad => this.loadImageDimensions(ad.imageUrl)));
1699
+ const validDimensions = imageDimensions
1700
+ .filter((result) => result.status === 'fulfilled')
1701
+ .map(result => result.value);
1702
+ if (validDimensions.length === 0) {
1703
+ return {
1704
+ width: '100%',
1705
+ height: this.getDefaultHeight(),
1706
+ aspectRatio: 16 / 9
1707
+ };
1708
+ }
1709
+ const strategy = this.selectOptimalSizeStrategy(validDimensions);
1710
+ const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
1711
+ if (this.debug) {
1712
+ console.log(`📐 Optimal banner container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
1713
+ }
1714
+ return {
1715
+ width: '100%',
1716
+ height: `${optimalHeight}px`,
1717
+ aspectRatio: containerWidth / optimalHeight
1718
+ };
1719
+ }
1720
+ catch (error) {
1721
+ console.warn('Failed to calculate optimal banner size, using defaults:', error);
1722
+ return {
1723
+ width: '100%',
1724
+ height: this.getDefaultHeight(),
1725
+ aspectRatio: 16 / 9
1726
+ };
1727
+ }
1728
+ }
1729
+ /**
1730
+ * 배너 광고를 위한 컨테이너 최적화
1731
+ */
1732
+ async optimizeContainerForBannerAds(slot, advertisements) {
1733
+ try {
1734
+ const container = document.getElementById(slot.containerId);
1735
+ const adElement = document.getElementById(slot.id);
1736
+ if (!container || !adElement)
1737
+ return;
1738
+ const containerWidth = container.getBoundingClientRect().width || 300;
1739
+ const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth);
1740
+ adElement.style.height = optimalSize.height;
1741
+ slot.optimizedHeight = optimalSize.height;
1742
+ slot.aspectRatio = optimalSize.aspectRatio;
1743
+ if (this.debug) {
1744
+ console.log(`🔧 Banner container optimized for ${advertisements.length} ads: ${optimalSize.height}`);
1745
+ }
1746
+ }
1747
+ catch (error) {
1748
+ console.warn('Banner container optimization failed, using default size:', error);
1749
+ }
1750
+ }
1751
+ /**
1752
+ * 최적 크기 조정 전략 선택
1753
+ */
1754
+ selectOptimalSizeStrategy(dimensions) {
1755
+ const aspectRatios = dimensions.map(d => d.width / d.height);
1756
+ const ratioGroups = new Map();
1757
+ aspectRatios.forEach(ratio => {
1758
+ const roundedRatio = Math.round(ratio * 10) / 10;
1759
+ const key = roundedRatio.toString();
1760
+ ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
1761
+ });
1762
+ const maxGroup = Math.max(...ratioGroups.values());
1763
+ const totalImages = dimensions.length;
1764
+ if (maxGroup / totalImages >= 0.7) {
1765
+ return 'dominant';
1766
+ }
1767
+ const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
1768
+ const standardCount = aspectRatios.filter(ratio => standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)).length;
1769
+ if (standardCount / totalImages >= 0.5) {
1770
+ return 'common';
1771
+ }
1772
+ return 'average';
1773
+ }
1774
+ /**
1775
+ * 전략에 따른 최적 높이 계산
1776
+ */
1777
+ calculateOptimalHeight(dimensions, containerWidth, strategy) {
1778
+ const aspectRatios = dimensions.map(d => d.width / d.height);
1779
+ switch (strategy) {
1780
+ case 'dominant': {
1781
+ const ratioGroups = new Map();
1782
+ aspectRatios.forEach(ratio => {
1783
+ const roundedRatio = Math.round(ratio * 10) / 10;
1784
+ const key = roundedRatio.toString();
1785
+ const existing = ratioGroups.get(key);
1786
+ if (existing) {
1787
+ existing.count++;
1788
+ }
1789
+ else {
1790
+ ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
1791
+ }
1792
+ });
1793
+ const dominantGroup = Array.from(ratioGroups.values()).reduce((max, current) => current.count > max.count ? current : max);
1794
+ return Math.round(containerWidth / dominantGroup.ratio);
1795
+ }
1796
+ case 'common': {
1797
+ const standardRatios = [
1798
+ { ratio: 16 / 9, name: '16:9' },
1799
+ { ratio: 4 / 3, name: '4:3' },
1800
+ { ratio: 1 / 1, name: '1:1' },
1801
+ { ratio: 3 / 2, name: '3:2' }
1802
+ ];
1803
+ const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1804
+ const bestStandard = standardRatios.reduce((best, current) => Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best);
1805
+ if (this.debug) {
1806
+ console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
1807
+ }
1808
+ return Math.round(containerWidth / bestStandard.ratio);
1809
+ }
1810
+ case 'average':
1811
+ default: {
1812
+ const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1813
+ return Math.round(containerWidth / averageRatio);
1814
+ }
1815
+ }
1816
+ }
1817
+ /**
1818
+ * 이미지 로드 및 실제 크기 획득
1819
+ */
1820
+ loadImageDimensions(imageUrl) {
1821
+ return new Promise((resolve, reject) => {
1822
+ const img = new Image();
1823
+ img.onload = () => {
1824
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
1825
+ };
1826
+ img.onerror = () => {
1827
+ reject(new Error(`Failed to load image: ${imageUrl}`));
1828
+ };
1829
+ img.src = imageUrl;
1830
+ });
1831
+ }
1832
+ /**
1833
+ * 이미지와 컨테이너 비율을 고려한 최적화 스타일 적용
1834
+ */
1835
+ applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio) {
1836
+ const ratio = imageAspectRatio / containerAspectRatio;
1837
+ if (Math.abs(ratio - 1) < 0.1) {
1838
+ img.style.objectFit = 'cover';
1839
+ img.style.objectPosition = 'center';
1840
+ }
1841
+ else if (ratio > 1.3) {
1842
+ img.style.objectFit = 'contain';
1843
+ img.style.objectPosition = 'center';
1844
+ img.style.backgroundColor = '#f0f0f0';
1845
+ }
1846
+ else if (ratio < 0.7) {
1847
+ img.style.objectFit = 'cover';
1848
+ img.style.objectPosition = 'center';
1849
+ }
1850
+ else {
1851
+ img.style.objectFit = 'cover';
1852
+ img.style.objectPosition = 'center';
1853
+ }
1854
+ if (this.debug) {
1855
+ console.log(`🎨 Banner image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
1856
+ }
1857
+ }
1858
+ /**
1859
+ * 배너 이미지 최적화 렌더링 - public으로 변경
1860
+ */
1861
+ async renderOptimizedBannerImage(container, advertisement, slot) {
1862
+ const img = document.createElement('img');
1863
+ img.style.width = '100%';
1864
+ img.style.height = '100%';
1865
+ img.style.display = 'block';
1866
+ img.style.borderRadius = '8px';
1867
+ img.alt = advertisement.title || 'Banner Advertisement';
1868
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #999;">Loading...</div>';
1869
+ try {
1870
+ if (!advertisement.imageUrl) {
1871
+ throw new Error('Image URL is not provided');
1872
+ }
1873
+ const imageDimensions = await this.loadImageDimensions(advertisement.imageUrl);
1874
+ const containerRect = container.getBoundingClientRect();
1875
+ const containerWidth = containerRect.width;
1876
+ const containerHeight = containerRect.height;
1877
+ if (this.debug) {
1878
+ console.log(`📸 Banner image dimensions: ${imageDimensions.width}x${imageDimensions.height}`);
1879
+ console.log(`📦 Banner container dimensions: ${containerWidth}x${containerHeight}`);
1880
+ }
1881
+ const imageAspectRatio = imageDimensions.width / imageDimensions.height;
1882
+ const containerAspectRatio = containerWidth / containerHeight;
1883
+ this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
1884
+ img.src = advertisement.imageUrl;
1885
+ container.innerHTML = '';
1886
+ container.appendChild(img);
1887
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1888
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1889
+ if (this.debug) {
1890
+ console.log(`✅ Optimized banner image rendered for ad: ${advertisement._id}`);
1891
+ }
1892
+ return img;
1893
+ }
1894
+ catch (error) {
1895
+ console.error('❌ Failed to load optimized banner image:', error);
1896
+ if (advertisement.imageUrl) {
1897
+ img.src = advertisement.imageUrl;
1898
+ img.style.objectFit = 'cover';
1899
+ img.style.objectPosition = 'center';
1900
+ container.innerHTML = '';
1901
+ container.appendChild(img);
1902
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1903
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1904
+ }
1905
+ return img;
1906
+ }
1907
+ }
1908
+ }
1909
+
1910
+ /**
1911
+ * 텍스트 광고 공통 유틸리티 함수들
1912
+ */
1913
+ class TextAdUtils {
1914
+ /**
1915
+ * 텍스트 광고의 기본 스타일을 생성
1916
+ * @param isSimple 간단한 스타일 여부
1917
+ * @returns CSS 스타일 문자열
1918
+ */
1919
+ static createTextAdStyles(isSimple = false) {
1920
+ const baseStyles = `
1921
+ font-family: 'Arial', sans-serif;
1922
+ text-decoration: none;
1923
+ display: block;
1924
+ text-align: left;
1925
+ transition: color 0.3s ease;
1926
+ width: 100%;
1927
+ box-sizing: border-box;
1928
+ overflow: visible;
1929
+ padding-right: 2px;
1930
+ padding-left: 2px;
1931
+ white-space: nowrap;
1932
+ `;
1933
+ return baseStyles;
1934
+ }
1935
+ /**
1936
+ * 텍스트 광고의 콘텐츠를 설정 (textContent 우선, 없으면 title)
1937
+ * @param element 설정할 HTML 요소
1938
+ * @param adData 광고 데이터
1939
+ */
1940
+ static setTextAdContent(element, adData) {
1941
+ const content = adData.textContent || adData.title || '';
1942
+ element.textContent = content;
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * 텍스트 전환 효과 관리 클래스
1948
+ * - 텍스트 광고 전용 페이드 인/아웃 + 상하 움직임 효과
1949
+ * - 부드러운 전환 애니메이션 (vertical transition)
1950
+ * - 무한 루프 지원
1951
+ */
1952
+ class TextTransitionManager {
1953
+ /**
1954
+ * 간단한 광고 요소 생성 (크기 측정용)
1955
+ */
1956
+ static createSimpleAdElement(slot, advertisement) {
1957
+ const adElement = document.createElement('div');
1958
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
1959
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
1960
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
1961
+ // 🎯 공통 스타일 및 콘텐츠 적용 (중복 제거)
1962
+ adElement.style.cssText = TextAdUtils.createTextAdStyles(true);
1963
+ TextAdUtils.setTextAdContent(adElement, advertisement);
1964
+ return adElement;
1965
+ }
1966
+ /**
1967
+ * 텍스트 전환 컨테이너 생성
1968
+ */
1969
+ static createTextTransitionContainer(slot, advertisements, options, trackEventCallback, debug = false) {
1970
+ // 사용자가 높이를 지정했는지 미리 확인 ('auto'나 undefined면 자동 높이)
1971
+ const hasUserDefinedHeight = slot.height && slot.height !== 0 && slot.height !== 'auto';
1972
+ const sliderWrapper = document.createElement('div');
1973
+ sliderWrapper.className = 'adstage-fade-slider-wrapper';
1974
+ // 래퍼 스타일 설정
1975
+ const containerStyles = {
1976
+ position: 'relative',
1977
+ overflow: 'hidden',
1978
+ display: 'block',
1979
+ };
1980
+ // 높이가 지정되지 않은 경우 자동 높이 사용
1981
+ if (!hasUserDefinedHeight) {
1982
+ containerStyles.height = 'auto';
1983
+ containerStyles.minHeight = 'fit-content';
1984
+ }
1985
+ // 사용자가 크기를 지정한 경우
1986
+ if (slot.width && slot.width !== 0) {
1987
+ let width;
1988
+ if (typeof slot.width === 'string') {
1989
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
1990
+ }
1991
+ else {
1992
+ width = `${slot.width}px`;
1993
+ }
1994
+ containerStyles.width = width;
1995
+ }
1996
+ // 사용자가 높이를 명시적으로 지정한 경우에만 적용 ('auto'는 제외)
1997
+ if (slot.height && slot.height !== 0 && slot.height !== 'auto' && hasUserDefinedHeight) {
1998
+ let height;
1999
+ if (typeof slot.height === 'string') {
2000
+ height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
2001
+ }
2002
+ else {
2003
+ height = `${slot.height}px`;
2004
+ }
2005
+ containerStyles.height = height;
2006
+ }
2007
+ // 스타일 적용
2008
+ Object.entries(containerStyles).forEach(([key, value]) => {
1581
2009
  sliderWrapper.style.setProperty(key, value);
1582
2010
  });
1583
2011
  // 슬라이드 컨테이너
@@ -1586,13 +2014,14 @@ class TextTransitionManager {
1586
2014
  slideContainer.style.cssText = `
1587
2015
  position: relative;
1588
2016
  width: 100%;
1589
- height: 100%;
2017
+ ${hasUserDefinedHeight ? 'height: 100%;' : 'height: auto; min-height: fit-content;'}
1590
2018
  `;
1591
2019
  // 크기 측정을 위한 임시 컨테이너 (자동 크기 계산이 필요한 경우)
1592
2020
  let measureContainer = null;
1593
2021
  const needsWidthMeasurement = !slot.width || slot.width === 0;
1594
- const needsHeightMeasurement = !slot.height || slot.height === 0;
1595
- if (needsWidthMeasurement || needsHeightMeasurement) {
2022
+ const needsHeightMeasurement = !slot.height || slot.height === 0 || slot.height === undefined || slot.height === 'auto';
2023
+ // 너비 측정만 필요한 경우 (높이는 자동으로 유지)
2024
+ if (needsWidthMeasurement || (needsHeightMeasurement && hasUserDefinedHeight)) {
1596
2025
  measureContainer = document.createElement('div');
1597
2026
  measureContainer.style.cssText = `
1598
2027
  position: absolute;
@@ -1618,7 +2047,7 @@ class TextTransitionManager {
1618
2047
  let maxHeight = 0;
1619
2048
  // 모든 광고의 크기를 측정하여 최대 크기 찾기
1620
2049
  advertisements.forEach(ad => {
1621
- const measureAdElement = AdRendererFactory.render(ad, slot, trackEventCallback);
2050
+ const measureAdElement = this.createSimpleAdElement(slot, ad);
1622
2051
  measureContainer.appendChild(measureAdElement);
1623
2052
  const rect = measureAdElement.getBoundingClientRect();
1624
2053
  if (rect.width > maxWidth)
@@ -1632,7 +2061,8 @@ class TextTransitionManager {
1632
2061
  if (needsWidthMeasurement && maxWidth > 0) {
1633
2062
  sliderWrapper.style.width = `${maxWidth}px`;
1634
2063
  }
1635
- if (needsHeightMeasurement && maxHeight > 0) {
2064
+ // 사용자가 높이를 지정하지 않은 경우 auto 높이 유지 (측정된 높이 적용하지 않음)
2065
+ if (needsHeightMeasurement && maxHeight > 0 && hasUserDefinedHeight) {
1636
2066
  sliderWrapper.style.height = `${maxHeight}px`;
1637
2067
  }
1638
2068
  // 측정 컨테이너 제거
@@ -1643,22 +2073,27 @@ class TextTransitionManager {
1643
2073
  advertisements.forEach((ad, index) => {
1644
2074
  const slideElement = document.createElement('div');
1645
2075
  slideElement.className = 'adstage-fade-slide';
2076
+ // 자동 높이일 때는 첫 번째 슬라이드만 relative로 높이 확보
2077
+ const isFirstSlide = index === 0;
2078
+ const positionStyle = hasUserDefinedHeight ? 'absolute' : (isFirstSlide ? 'relative' : 'absolute');
1646
2079
  slideElement.style.cssText = `
1647
- position: absolute;
1648
- top: 0;
1649
- left: 0;
2080
+ position: ${positionStyle};
2081
+ top: ${positionStyle === 'absolute' ? '0' : 'auto'};
2082
+ left: ${positionStyle === 'absolute' ? '0' : 'auto'};
1650
2083
  width: 100%;
1651
- height: 100%;
2084
+ ${hasUserDefinedHeight ? 'height: 100%;' : 'height: auto;'}
1652
2085
  display: flex;
1653
2086
  align-items: center;
1654
- justify-content: center;
2087
+ justify-content: flex-start;
1655
2088
  opacity: ${index === 0 ? '1' : '0'};
1656
2089
  transform: translateY(${index === 0 ? '0' : '20px'});
1657
2090
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
1658
2091
  z-index: ${index === 0 ? '2' : '1'};
1659
2092
  `;
1660
- // 광고 렌더링
1661
- const adElement = AdRendererFactory.render(ad, slot, trackEventCallback);
2093
+ // 광고 렌더링 - 공통 함수 사용으로 일관성 유지
2094
+ const adElement = this.createSimpleAdElement(slot, ad);
2095
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2096
+ AdClickHandler.addClickEventForSlider(adElement, ad, slot, trackEventCallback, debug, 'Text');
1662
2097
  slideElement.appendChild(adElement);
1663
2098
  slideContainer.appendChild(slideElement);
1664
2099
  slideElements.push(slideElement);
@@ -1677,6 +2112,19 @@ class TextTransitionManager {
1677
2112
  }
1678
2113
  const previousSlide = slideElements[currentSlide];
1679
2114
  const nextSlide = slideElements[index];
2115
+ // 자동 높이 모드일 때는 위치 변경
2116
+ if (!hasUserDefinedHeight) {
2117
+ // 이전 슬라이드를 absolute로 변경
2118
+ if (previousSlide.style.position === 'relative') {
2119
+ previousSlide.style.position = 'absolute';
2120
+ previousSlide.style.top = '0';
2121
+ previousSlide.style.left = '0';
2122
+ }
2123
+ // 다음 슬라이드를 relative로 변경하여 높이 확보
2124
+ nextSlide.style.position = 'relative';
2125
+ nextSlide.style.top = 'auto';
2126
+ nextSlide.style.left = 'auto';
2127
+ }
1680
2128
  // 이전 슬라이드 페이드 아웃 (아래로)
1681
2129
  previousSlide.style.opacity = '0';
1682
2130
  previousSlide.style.transform = 'translateY(-20px)';
@@ -1691,13 +2139,18 @@ class TextTransitionManager {
1691
2139
  slide.style.opacity = '0';
1692
2140
  slide.style.transform = 'translateY(20px)';
1693
2141
  slide.style.zIndex = '1';
2142
+ // 자동 높이 모드일 때는 다른 슬라이드들을 absolute로
2143
+ if (!hasUserDefinedHeight && slide.style.position === 'relative') {
2144
+ slide.style.position = 'absolute';
2145
+ slide.style.top = '0';
2146
+ slide.style.left = '0';
2147
+ }
1694
2148
  }
1695
2149
  });
1696
2150
  currentSlide = index;
1697
- // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
1698
- if (currentSlide > 0) { // 번째는 이미 loadSlot에서 추적됨
1699
- trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.VIEWABLE);
1700
- }
2151
+ // 🎯 공통 슬라이더 이벤트 추적 적용 (모든 슬라이드 포함)
2152
+ SliderEventTracker.trackSlideViewable(advertisements[currentSlide], slot, currentSlide, trackEventCallback, debug // debug 모드 상속
2153
+ );
1701
2154
  };
1702
2155
  // 자동 슬라이드
1703
2156
  let autoSlideTimer = setInterval(() => {
@@ -1762,496 +2215,975 @@ class TextTransitionManager {
1762
2215
  }
1763
2216
 
1764
2217
  /**
1765
- * AdRenderer - 광고 렌더링 전용 클래스
1766
- * AdsModule에서 렌더링 관련 기능을 분리
2218
+ * 텍스트 광고 전용 렌더러
1767
2219
  */
1768
- class AdRenderer {
2220
+ class TextAdRenderer extends BaseAdRenderer {
1769
2221
  constructor(debug = false, advertisementEventTracker) {
1770
- this.debug = debug;
1771
- this.advertisementEventTracker = advertisementEventTracker || null;
2222
+ super(AdType.TEXT, debug, advertisementEventTracker);
1772
2223
  }
1773
2224
  /**
1774
- * Placeholder(슬롯 컨테이너) 생성
2225
+ * 텍스트 광고 기본 높이
2226
+ */
2227
+ getDefaultHeight() {
2228
+ return '60px';
2229
+ }
2230
+ /**
2231
+ * 단일 텍스트 광고 렌더링
1775
2232
  */
1776
- createPlaceholder(container, slotId, type, options, _config) {
2233
+ async renderAdElement(slot, advertisement) {
2234
+ const container = document.getElementById(slot.containerId);
2235
+ if (!container)
2236
+ return;
1777
2237
  const adElement = document.createElement('div');
1778
- adElement.id = slotId;
1779
- adElement.className = `adstage-slot adstage-${String(type).toLowerCase()}`;
1780
- adElement.setAttribute('data-adstage-container', 'true');
1781
- adElement.setAttribute('data-adstage-type', String(type));
1782
- adElement.setAttribute('data-adstage-slot', slotId);
1783
- const { width, height } = this.calculateAdSize(container, type, options, _config) || {
1784
- width: '100%',
1785
- height: '250px'
1786
- };
1787
- adElement.style.width = width;
1788
- adElement.style.height = height;
1789
- adElement.style.border = '1px dashed #ccc';
1790
- adElement.style.display = 'flex';
1791
- adElement.style.alignItems = 'center';
1792
- adElement.style.justifyContent = 'center';
1793
- adElement.style.backgroundColor = '#f9f9f9';
1794
- adElement.style.color = '#666';
1795
- adElement.innerHTML = `<span>Loading ${type} ad...</span>`;
2238
+ adElement.className = 'adstage-ad adstage-text-ad';
2239
+ const optimizedHeight = slot.optimizedHeight;
2240
+ const containerElement = container.parentElement || container;
2241
+ if (optimizedHeight) {
2242
+ adElement.style.width = '100%';
2243
+ adElement.style.height = String(optimizedHeight);
2244
+ }
2245
+ else {
2246
+ const config = slot.config;
2247
+ const options = {
2248
+ width: config?.width,
2249
+ height: config?.height
2250
+ };
2251
+ const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
2252
+ adElement.style.width = width;
2253
+ adElement.style.height = height;
2254
+ }
2255
+ // 텍스트 콘텐츠 렌더링
2256
+ const textDiv = document.createElement('div');
2257
+ textDiv.className = 'adstage-text-content';
2258
+ textDiv.style.cssText = TextAdUtils.createTextAdStyles(false);
2259
+ TextAdUtils.setTextAdContent(textDiv, advertisement); // 최대 라인 수 제한
2260
+ const maxLines = slot.config?.maxLines;
2261
+ if (maxLines && typeof maxLines === 'number') {
2262
+ textDiv.style.display = '-webkit-box';
2263
+ textDiv.style.webkitLineClamp = String(maxLines);
2264
+ textDiv.style.webkitBoxOrient = 'vertical';
2265
+ textDiv.style.overflow = 'hidden';
2266
+ }
2267
+ adElement.appendChild(textDiv);
2268
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2269
+ AdClickHandler.addClickEventForRenderer(adElement, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Text');
2270
+ container.innerHTML = '';
1796
2271
  container.appendChild(adElement);
1797
2272
  if (this.debug) {
1798
- console.log(`📦 Placeholder created for slot: ${slotId} (${width} x ${height})`);
2273
+ console.log(`✨ Single text ad rendered: ${advertisement._id}`);
1799
2274
  }
1800
2275
  }
1801
2276
  /**
1802
- * 여러 광고의 최적 컨테이너 크기 계산 (동적 크기 조정)
2277
+ * 다중 텍스트 광고 렌더링 (전환 효과)
1803
2278
  */
1804
- async calculateOptimalContainerSize(advertisements, containerWidth, adType) {
1805
- if (!advertisements.length || adType !== AdType.BANNER) {
1806
- return {
1807
- width: '100%',
1808
- height: this.getDefaultHeightForAdType(adType) || '250px',
1809
- aspectRatio: 16 / 9
1810
- };
2279
+ async renderMultipleAds(slot, advertisements) {
2280
+ const container = document.getElementById(slot.containerId);
2281
+ if (!container) {
2282
+ throw new Error(`Container not found: ${slot.containerId}`);
1811
2283
  }
1812
- try {
1813
- const imageDimensions = await Promise.allSettled(advertisements
1814
- .filter(ad => ad.imageUrl)
1815
- .map(ad => this.loadImageDimensions(ad.imageUrl)));
1816
- const validDimensions = imageDimensions
1817
- .filter((result) => result.status === 'fulfilled')
1818
- .map(result => result.value);
1819
- if (validDimensions.length === 0) {
1820
- return {
1821
- width: '100%',
1822
- height: this.getDefaultHeightForAdType(adType) || '250px',
1823
- aspectRatio: 16 / 9
1824
- };
2284
+ const trackEventCallback = this.createEventTrackingCallback();
2285
+ const optimizedSliderOptions = {
2286
+ autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
2287
+ ...slot.config,
2288
+ optimizedHeight: slot.optimizedHeight,
2289
+ aspectRatio: slot.aspectRatio
2290
+ };
2291
+ const sliderElement = TextTransitionManager.createTextTransitionContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback, this.debug);
2292
+ if (sliderElement) {
2293
+ container.innerHTML = '';
2294
+ container.appendChild(sliderElement);
2295
+ if (this.debug) {
2296
+ console.log(`✨ Text transition created for slot: ${slot.id} with ${advertisements.length} ads`);
1825
2297
  }
1826
- const strategy = this.selectOptimalSizeStrategy(validDimensions);
1827
- const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
2298
+ }
2299
+ }
2300
+ }
2301
+
2302
+ /**
2303
+ * 비디오 광고 전용 렌더러
2304
+ */
2305
+ class VideoAdRenderer extends BaseAdRenderer {
2306
+ constructor(debug = false, advertisementEventTracker) {
2307
+ super(AdType.VIDEO, debug, advertisementEventTracker);
2308
+ }
2309
+ /**
2310
+ * 비디오 광고 기본 높이 (16:9 비율 고려)
2311
+ */
2312
+ getDefaultHeight() {
2313
+ return '360px';
2314
+ }
2315
+ /**
2316
+ * 단일 비디오 광고 렌더링
2317
+ */
2318
+ async renderAdElement(slot, advertisement) {
2319
+ const container = document.getElementById(slot.containerId);
2320
+ if (!container)
2321
+ return;
2322
+ // 비디오 전용 컨테이너 생성
2323
+ const videoContainer = document.createElement('div');
2324
+ videoContainer.className = 'adstage-video-container';
2325
+ videoContainer.style.cssText = `
2326
+ width: 100%;
2327
+ height: 100%;
2328
+ display: flex;
2329
+ align-items: center;
2330
+ justify-content: center;
2331
+ background: #000;
2332
+ border-radius: 8px;
2333
+ overflow: hidden;
2334
+ `;
2335
+ // 비디오 요소 렌더링
2336
+ this.renderVideoElementDirect(videoContainer, advertisement, slot);
2337
+ container.innerHTML = '';
2338
+ container.appendChild(videoContainer);
2339
+ if (this.debug) {
2340
+ console.log(`🎬 Single video ad rendered: ${advertisement._id}`);
2341
+ }
2342
+ }
2343
+ /**
2344
+ * 다중 비디오 광고 렌더링 - 비디오는 단일 렌더링만 지원
2345
+ */
2346
+ async renderMultipleAds(slot, advertisements) {
2347
+ const container = document.getElementById(slot.containerId);
2348
+ if (!container) {
2349
+ throw new Error(`Container not found: ${slot.containerId}`);
2350
+ }
2351
+ if (advertisements.length > 0) {
2352
+ const videoAd = advertisements[0]; // 첫 번째 비디오만 사용
2353
+ // 비디오 전용 컨테이너 생성
2354
+ const videoContainer = document.createElement('div');
2355
+ videoContainer.className = 'adstage-video-container';
2356
+ videoContainer.style.cssText = `
2357
+ width: 100%;
2358
+ height: 100%;
2359
+ display: flex;
2360
+ align-items: center;
2361
+ justify-content: center;
2362
+ background: #000;
2363
+ border-radius: 8px;
2364
+ overflow: hidden;
2365
+ `;
2366
+ // 비디오 요소 렌더링
2367
+ this.renderVideoElementDirect(videoContainer, videoAd, slot);
2368
+ // 컨테이너에 추가
2369
+ container.innerHTML = '';
2370
+ container.appendChild(videoContainer);
2371
+ // VIEWABLE 이벤트 추적
2372
+ const trackEventCallback = this.createEventTrackingCallback();
2373
+ setTimeout(() => {
2374
+ trackEventCallback(videoAd._id, slot.id, AdEventType.VIEWABLE);
2375
+ }, 100);
1828
2376
  if (this.debug) {
1829
- console.log(`📐 Optimal container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
2377
+ console.log(`🎬 Single video rendered for VIDEO slot: ${slot.id} (${videoAd._id})`);
1830
2378
  }
1831
- return {
1832
- width: '100%',
1833
- height: `${optimalHeight}px`,
1834
- aspectRatio: containerWidth / optimalHeight
1835
- };
1836
2379
  }
1837
- catch (error) {
1838
- console.warn('Failed to calculate optimal size, using defaults:', error);
1839
- return {
1840
- width: '100%',
1841
- height: this.getDefaultHeightForAdType(adType) || '250px',
1842
- aspectRatio: 16 / 9
1843
- };
2380
+ else {
2381
+ if (this.debug) {
2382
+ console.warn(`⚠️ No video advertisements available for slot: ${slot.id}`);
2383
+ }
1844
2384
  }
1845
2385
  }
1846
2386
  /**
1847
- * 최적 크기 조정 전략 선택
2387
+ * 비디오 요소 직접 렌더링
1848
2388
  */
1849
- selectOptimalSizeStrategy(dimensions) {
1850
- const aspectRatios = dimensions.map(d => d.width / d.height);
1851
- const ratioGroups = new Map();
1852
- aspectRatios.forEach(ratio => {
1853
- const roundedRatio = Math.round(ratio * 10) / 10;
1854
- const key = roundedRatio.toString();
1855
- ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
2389
+ renderVideoElementDirect(container, advertisement, slot) {
2390
+ // 비디오 컨테이너를 relative로 설정
2391
+ container.style.position = 'relative';
2392
+ const video = document.createElement('video');
2393
+ // 비디오 기본 설정
2394
+ video.style.width = '100%';
2395
+ video.style.height = '100%';
2396
+ video.style.objectFit = 'contain';
2397
+ video.preload = 'metadata';
2398
+ // 슬롯 설정에서 비디오 옵션들 확인 (기본값 적용)
2399
+ const config = slot.config;
2400
+ // 디버깅: config 내용 확인
2401
+ if (this.debug) {
2402
+ console.log('🎬 Video config received:', config);
2403
+ }
2404
+ // 기본 설정: 모든 컨트롤 숨김 (사용자 요구사항 - config에 상관없이 기본값 적용)
2405
+ video.controls = false;
2406
+ // 기본 설정: 자동 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
2407
+ video.autoplay = config?.autoplay === false ? false : true;
2408
+ // 기본 설정: 음소거 true (기본값 강제 적용)
2409
+ video.muted = true;
2410
+ // 기본 설정: 반복 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
2411
+ video.loop = config?.loop === false ? false : true;
2412
+ // 디버깅: 최종 비디오 설정 확인
2413
+ if (this.debug) {
2414
+ console.log('🎬 Final video settings:', {
2415
+ autoplay: video.autoplay,
2416
+ muted: video.muted,
2417
+ loop: video.loop,
2418
+ controls: video.controls
2419
+ });
2420
+ }
2421
+ // playsinline 설정 (모바일에서 전체화면 방지)
2422
+ if (config?.playsinline !== false) {
2423
+ video.setAttribute('playsinline', '');
2424
+ }
2425
+ // 사용자가 명시적으로 controls=true를 설정한 경우에만 오버라이드 (첫 번째 비디오는 기본값 유지)
2426
+ if (config?.controls === true) {
2427
+ video.controls = true;
2428
+ if (this.debug) {
2429
+ console.log('🎬 User explicitly enabled controls, overriding default');
2430
+ }
2431
+ }
2432
+ // 특별한 컨트롤 설정 처리
2433
+ if (config?.hideControls) {
2434
+ // 완전히 모든 컨트롤 숨김 (pointer-events까지 차단)
2435
+ video.controls = false;
2436
+ video.style.cssText += `
2437
+ pointer-events: none;
2438
+ `;
2439
+ }
2440
+ else if (config?.customControls) {
2441
+ // controls가 true일 때만 특정 컨트롤 숨기기 (CSS로 처리)
2442
+ if (video.controls) {
2443
+ const customControlsStyle = document.createElement('style');
2444
+ customControlsStyle.textContent = `
2445
+ video::-webkit-media-controls-play-button {
2446
+ display: ${config.customControls.hidePlayButton ? 'none' : 'block'} !important;
2447
+ }
2448
+ video::-webkit-media-controls-timeline {
2449
+ display: ${config.customControls.hideProgressBar ? 'none' : 'block'} !important;
2450
+ }
2451
+ video::-webkit-media-controls-current-time-display {
2452
+ display: ${config.customControls.hideCurrentTime ? 'none' : 'block'} !important;
2453
+ }
2454
+ video::-webkit-media-controls-time-remaining-display {
2455
+ display: ${config.customControls.hideRemainingTime ? 'none' : 'block'} !important;
2456
+ }
2457
+ video::-webkit-media-controls-volume-slider {
2458
+ display: ${config.customControls.hideVolumeSlider ? 'none' : 'block'} !important;
2459
+ }
2460
+ video::-webkit-media-controls-mute-button {
2461
+ display: ${config.customControls.hideMuteButton ? 'none' : 'block'} !important;
2462
+ }
2463
+ video::-webkit-media-controls-fullscreen-button {
2464
+ display: ${config.customControls.hideFullscreenButton ? 'none' : 'block'} !important;
2465
+ }
2466
+ `;
2467
+ document.head.appendChild(customControlsStyle);
2468
+ }
2469
+ }
2470
+ else if (video.controls) {
2471
+ // controls=true이지만 특별한 설정이 없을 때, 기본 스타일 적용 (시간만 표시)
2472
+ const defaultControlsStyle = document.createElement('style');
2473
+ defaultControlsStyle.id = 'adstage-video-default-controls';
2474
+ defaultControlsStyle.textContent = `
2475
+ .adstage-video-container video::-webkit-media-controls-mute-button {
2476
+ display: none !important;
2477
+ }
2478
+ .adstage-video-container video::-webkit-media-controls-fullscreen-button {
2479
+ display: none !important;
2480
+ }
2481
+ .adstage-video-container video::-webkit-media-controls-toggle-closed-captions-button {
2482
+ display: none !important;
2483
+ }
2484
+ .adstage-video-container video::-webkit-media-controls-volume-slider {
2485
+ display: none !important;
2486
+ }
2487
+ .adstage-video-container video::-webkit-media-controls-overflow-button {
2488
+ display: none !important;
2489
+ }
2490
+ .adstage-video-container video::-webkit-media-controls-picture-in-picture-button {
2491
+ display: none !important;
2492
+ }
2493
+ video::-webkit-media-controls-mute-button {
2494
+ display: none !important;
2495
+ }
2496
+ video::-webkit-media-controls-fullscreen-button {
2497
+ display: none !important;
2498
+ }
2499
+ video::-webkit-media-controls-toggle-closed-captions-button {
2500
+ display: none !important;
2501
+ }
2502
+ video::-webkit-media-controls-volume-slider {
2503
+ display: none !important;
2504
+ }
2505
+ video::-webkit-media-controls-overflow-button {
2506
+ display: none !important;
2507
+ }
2508
+ video::-webkit-media-controls-picture-in-picture-button {
2509
+ display: none !important;
2510
+ }
2511
+ `;
2512
+ // 스타일이 이미 존재하지 않으면 추가
2513
+ if (!document.getElementById('adstage-video-default-controls')) {
2514
+ document.head.appendChild(defaultControlsStyle);
2515
+ }
2516
+ }
2517
+ // 기본값이 controls=false이므로 아무것도 표시하지 않음
2518
+ if (this.debug) {
2519
+ console.log('🎬 Video controls setting:', video.controls ? 'enabled' : 'disabled (default)');
2520
+ }
2521
+ // 커스텀 음소거 토글 버튼 생성 (기본적으로 항상 표시)
2522
+ const muteButton = document.createElement('button');
2523
+ muteButton.className = 'adstage-video-mute-button';
2524
+ muteButton.style.cssText = `
2525
+ position: absolute;
2526
+ top: 12px;
2527
+ left: 12px;
2528
+ width: 40px;
2529
+ height: 40px;
2530
+ border: none;
2531
+ border-radius: 50%;
2532
+ background: rgba(0, 0, 0, 0.7);
2533
+ color: white;
2534
+ font-size: 16px;
2535
+ cursor: pointer;
2536
+ display: flex;
2537
+ align-items: center;
2538
+ justify-content: center;
2539
+ z-index: 10;
2540
+ transition: all 0.3s ease;
2541
+ backdrop-filter: blur(4px);
2542
+ `;
2543
+ // hideControls가 true면 음소거 버튼도 숨김
2544
+ if (config?.hideControls) {
2545
+ muteButton.style.display = 'none';
2546
+ }
2547
+ // 음소거 상태에 따른 아이콘 업데이트
2548
+ const updateMuteButtonIcon = () => {
2549
+ const mutedIcon = `
2550
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
2551
+ <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
2552
+ </svg>
2553
+ `;
2554
+ const unmutedIcon = `
2555
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
2556
+ <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
2557
+ </svg>
2558
+ `;
2559
+ muteButton.innerHTML = video.muted ? mutedIcon : unmutedIcon;
2560
+ muteButton.title = video.muted ? 'Click to unmute' : 'Click to mute';
2561
+ };
2562
+ // 초기 아이콘 설정
2563
+ updateMuteButtonIcon();
2564
+ // 음소거 버튼 클릭 이벤트
2565
+ muteButton.addEventListener('click', (e) => {
2566
+ e.stopPropagation(); // 비디오 클릭 이벤트 방지
2567
+ video.muted = !video.muted;
2568
+ updateMuteButtonIcon();
2569
+ if (this.debug) {
2570
+ console.log(`🔊 Video mute toggled: ${video.muted ? 'muted' : 'unmuted'}`);
2571
+ }
1856
2572
  });
1857
- const maxGroup = Math.max(...ratioGroups.values());
1858
- const totalImages = dimensions.length;
1859
- if (maxGroup / totalImages >= 0.7) {
1860
- return 'dominant';
2573
+ // 마우스 호버 효과
2574
+ muteButton.addEventListener('mouseenter', () => {
2575
+ muteButton.style.background = 'rgba(0, 0, 0, 0.9)';
2576
+ muteButton.style.transform = 'scale(1.1)';
2577
+ });
2578
+ muteButton.addEventListener('mouseleave', () => {
2579
+ muteButton.style.background = 'rgba(0, 0, 0, 0.7)';
2580
+ muteButton.style.transform = 'scale(1)';
2581
+ });
2582
+ // 비디오 URL 설정
2583
+ if (advertisement.videoUrl) {
2584
+ video.src = advertisement.videoUrl;
2585
+ // 자동 재생을 위한 다단계 시도
2586
+ const attemptAutoplay = () => {
2587
+ if (video.autoplay && video.muted && video.paused) {
2588
+ video.play().catch(error => {
2589
+ if (this.debug) {
2590
+ console.warn('🎬 Auto-play was prevented:', error);
2591
+ console.warn('🎬 Trying muted autoplay fallback...');
2592
+ }
2593
+ // 음소거 상태에서 다시 시도
2594
+ video.muted = true;
2595
+ video.play().catch(fallbackError => {
2596
+ if (this.debug) {
2597
+ console.error('🎬 Autoplay completely failed:', fallbackError);
2598
+ }
2599
+ });
2600
+ });
2601
+ }
2602
+ };
2603
+ // 다양한 이벤트에서 자동 재생 시도
2604
+ video.addEventListener('loadedmetadata', () => {
2605
+ if (this.debug) {
2606
+ console.log('🎬 Video metadata loaded, attempting autoplay...');
2607
+ }
2608
+ attemptAutoplay();
2609
+ });
2610
+ video.addEventListener('canplay', () => {
2611
+ if (this.debug) {
2612
+ console.log('🎬 Video can play, attempting autoplay...');
2613
+ }
2614
+ attemptAutoplay();
2615
+ });
2616
+ video.addEventListener('loadeddata', () => {
2617
+ if (this.debug) {
2618
+ console.log('🎬 Video data loaded, attempting autoplay...');
2619
+ }
2620
+ attemptAutoplay();
2621
+ });
2622
+ // 비디오 로드 시작 즉시 한 번 시도
2623
+ setTimeout(() => {
2624
+ if (this.debug) {
2625
+ console.log('🎬 Initial autoplay attempt after timeout...');
2626
+ }
2627
+ attemptAutoplay();
2628
+ }, 100);
2629
+ }
2630
+ else if (advertisement.imageUrl) {
2631
+ // 비디오 URL이 없으면 이미지를 대체 표시
2632
+ const img = document.createElement('img');
2633
+ img.src = advertisement.imageUrl;
2634
+ img.style.width = '100%';
2635
+ img.style.height = '100%';
2636
+ img.style.objectFit = 'contain';
2637
+ img.alt = advertisement.title || 'Video thumbnail';
2638
+ container.appendChild(img);
2639
+ return;
2640
+ }
2641
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2642
+ AdClickHandler.addClickEventForRenderer(video, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Video');
2643
+ // 비디오 로드 에러 처리
2644
+ video.addEventListener('error', (e) => {
2645
+ console.error('Video load failed:', e);
2646
+ // 대체 이미지 표시
2647
+ if (advertisement.imageUrl) {
2648
+ const img = document.createElement('img');
2649
+ img.src = advertisement.imageUrl;
2650
+ img.style.width = '100%';
2651
+ img.style.height = '100%';
2652
+ img.style.objectFit = 'contain';
2653
+ img.alt = advertisement.title || 'Video thumbnail';
2654
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2655
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Video fallback');
2656
+ container.innerHTML = '';
2657
+ container.appendChild(img);
2658
+ }
2659
+ });
2660
+ // 비디오와 음소거 버튼을 컨테이너에 추가
2661
+ container.appendChild(video);
2662
+ container.appendChild(muteButton);
2663
+ if (this.debug) {
2664
+ console.log(`🎬 Video element created for ad: ${advertisement._id} (autoplay: ${video.autoplay}, muted: ${video.muted}, loop: ${video.loop})`);
2665
+ }
2666
+ }
2667
+ }
2668
+
2669
+ /**
2670
+ * 네이티브 광고 전용 렌더러
2671
+ */
2672
+ class NativeAdRenderer extends BaseAdRenderer {
2673
+ constructor(debug = false, advertisementEventTracker) {
2674
+ super(AdType.NATIVE, debug, advertisementEventTracker);
2675
+ }
2676
+ /**
2677
+ * 네이티브 광고 기본 높이
2678
+ */
2679
+ getDefaultHeight() {
2680
+ return '200px';
2681
+ }
2682
+ /**
2683
+ * 단일 네이티브 광고 렌더링
2684
+ */
2685
+ async renderAdElement(slot, advertisement) {
2686
+ const container = document.getElementById(slot.containerId);
2687
+ if (!container)
2688
+ return;
2689
+ const adElement = document.createElement('div');
2690
+ adElement.className = 'adstage-ad adstage-native-ad';
2691
+ adElement.style.cssText = `
2692
+ width: 100%;
2693
+ height: 100%;
2694
+ display: flex;
2695
+ flex-direction: column;
2696
+ padding: 16px;
2697
+ background: #fff;
2698
+ border: 1px solid #e5e7eb;
2699
+ border-radius: 8px;
2700
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
2701
+ `;
2702
+ // 제목
2703
+ if (advertisement.title) {
2704
+ const title = document.createElement('h3');
2705
+ title.textContent = advertisement.title;
2706
+ title.style.cssText = `
2707
+ margin: 0 0 8px 0;
2708
+ font-size: 16px;
2709
+ font-weight: 600;
2710
+ color: #111827;
2711
+ line-height: 1.3;
2712
+ `;
2713
+ adElement.appendChild(title);
2714
+ }
2715
+ // 설명
2716
+ if (advertisement.textContent) {
2717
+ const description = document.createElement('p');
2718
+ description.textContent = advertisement.textContent;
2719
+ description.style.cssText = `
2720
+ margin: 0 0 12px 0;
2721
+ font-size: 14px;
2722
+ color: #6b7280;
2723
+ line-height: 1.4;
2724
+ flex: 1;
2725
+ `;
2726
+ adElement.appendChild(description);
2727
+ }
2728
+ // 이미지 (있는 경우)
2729
+ if (advertisement.imageUrl) {
2730
+ const imageContainer = document.createElement('div');
2731
+ imageContainer.style.cssText = `
2732
+ width: 100%;
2733
+ height: 120px;
2734
+ margin-bottom: 12px;
2735
+ border-radius: 6px;
2736
+ overflow: hidden;
2737
+ background: #f3f4f6;
2738
+ `;
2739
+ const img = document.createElement('img');
2740
+ img.src = advertisement.imageUrl;
2741
+ img.alt = advertisement.title || 'Native Advertisement';
2742
+ img.style.cssText = `
2743
+ width: 100%;
2744
+ height: 100%;
2745
+ object-fit: cover;
2746
+ `;
2747
+ imageContainer.appendChild(img);
2748
+ adElement.appendChild(imageContainer);
2749
+ }
2750
+ // CTA 버튼 (링크가 있는 경우)
2751
+ if (advertisement.linkUrl) {
2752
+ const ctaButton = document.createElement('button');
2753
+ ctaButton.textContent = 'Learn More';
2754
+ ctaButton.style.cssText = `
2755
+ padding: 8px 16px;
2756
+ background: #3b82f6;
2757
+ color: white;
2758
+ border: none;
2759
+ border-radius: 6px;
2760
+ font-size: 14px;
2761
+ font-weight: 500;
2762
+ cursor: pointer;
2763
+ align-self: flex-start;
2764
+ transition: background-color 0.2s;
2765
+ `;
2766
+ ctaButton.addEventListener('click', () => {
2767
+ if (this.advertisementEventTracker) {
2768
+ console.log(`Native click tracked for ad: ${advertisement._id}`);
2769
+ }
2770
+ window.open(advertisement.linkUrl, '_blank');
2771
+ });
2772
+ ctaButton.addEventListener('mouseenter', () => {
2773
+ ctaButton.style.backgroundColor = '#2563eb';
2774
+ });
2775
+ ctaButton.addEventListener('mouseleave', () => {
2776
+ ctaButton.style.backgroundColor = '#3b82f6';
2777
+ });
2778
+ adElement.appendChild(ctaButton);
1861
2779
  }
1862
- const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
1863
- const standardCount = aspectRatios.filter(ratio => standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)).length;
1864
- if (standardCount / totalImages >= 0.5) {
1865
- return 'common';
2780
+ container.innerHTML = '';
2781
+ container.appendChild(adElement);
2782
+ if (this.debug) {
2783
+ console.log(`🏠 Single native ad rendered: ${advertisement._id}`);
1866
2784
  }
1867
- return 'average';
1868
2785
  }
1869
2786
  /**
1870
- * 전략에 따른 최적 높이 계산
2787
+ * 다중 네이티브 광고 렌더링 - 현재는 단일 렌더링만 지원
1871
2788
  */
1872
- calculateOptimalHeight(dimensions, containerWidth, strategy) {
1873
- const aspectRatios = dimensions.map(d => d.width / d.height);
1874
- switch (strategy) {
1875
- case 'dominant': {
1876
- const ratioGroups = new Map();
1877
- aspectRatios.forEach(ratio => {
1878
- const roundedRatio = Math.round(ratio * 10) / 10;
1879
- const key = roundedRatio.toString();
1880
- const existing = ratioGroups.get(key);
1881
- if (existing) {
1882
- existing.count++;
1883
- }
1884
- else {
1885
- ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
1886
- }
1887
- });
1888
- const dominantGroup = Array.from(ratioGroups.values()).reduce((max, current) => current.count > max.count ? current : max);
1889
- return Math.round(containerWidth / dominantGroup.ratio);
1890
- }
1891
- case 'common': {
1892
- const standardRatios = [
1893
- { ratio: 16 / 9, name: '16:9' },
1894
- { ratio: 4 / 3, name: '4:3' },
1895
- { ratio: 1 / 1, name: '1:1' },
1896
- { ratio: 3 / 2, name: '3:2' }
1897
- ];
1898
- const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1899
- const bestStandard = standardRatios.reduce((best, current) => Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best);
1900
- if (this.debug) {
1901
- console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
1902
- }
1903
- return Math.round(containerWidth / bestStandard.ratio);
2789
+ async renderMultipleAds(slot, advertisements) {
2790
+ if (advertisements.length > 0) {
2791
+ await this.renderAdElement(slot, advertisements[0]);
2792
+ if (this.debug) {
2793
+ console.log(`🏠 Native ad rendered (first of ${advertisements.length}): ${slot.id}`);
1904
2794
  }
1905
- case 'average':
1906
- default: {
1907
- const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1908
- return Math.round(containerWidth / averageRatio);
2795
+ }
2796
+ else {
2797
+ if (this.debug) {
2798
+ console.warn(`⚠️ No native advertisements available for slot: ${slot.id}`);
1909
2799
  }
1910
2800
  }
1911
2801
  }
2802
+ }
2803
+
2804
+ /**
2805
+ * 전면광고 전용 렌더러
2806
+ */
2807
+ class InterstitialAdRenderer extends BaseAdRenderer {
2808
+ constructor(debug = false, advertisementEventTracker) {
2809
+ super(AdType.INTERSTITIAL, debug, advertisementEventTracker);
2810
+ }
1912
2811
  /**
1913
- * 배너 광고를 위한 컨테이너 최적화
2812
+ * 전면광고 기본 높이 (크게 설정)
1914
2813
  */
1915
- async optimizeContainerForBannerAds(slot, advertisements) {
1916
- try {
1917
- const container = document.getElementById(slot.containerId);
1918
- const adElement = document.getElementById(slot.id);
1919
- if (!container || !adElement)
1920
- return;
1921
- const containerWidth = container.getBoundingClientRect().width || 300;
1922
- const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth, slot.adType);
1923
- adElement.style.height = optimalSize.height;
1924
- slot.optimizedHeight = optimalSize.height;
1925
- slot.aspectRatio = optimalSize.aspectRatio;
1926
- if (this.debug) {
1927
- console.log(`🔧 Container optimized for ${advertisements.length} banner ads: ${optimalSize.height}`);
1928
- }
1929
- }
1930
- catch (error) {
1931
- console.warn('Container optimization failed, using default size:', error);
1932
- }
2814
+ getDefaultHeight() {
2815
+ return '400px';
1933
2816
  }
1934
2817
  /**
1935
- * 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
2818
+ * 단일 전면광고 렌더링
1936
2819
  */
1937
- async renderAdSlider(slot, advertisements) {
2820
+ async renderAdElement(slot, advertisement) {
1938
2821
  const container = document.getElementById(slot.containerId);
1939
- if (!container) {
1940
- throw new Error(`Container not found: ${slot.containerId}`);
2822
+ if (!container)
2823
+ return;
2824
+ // 전면광고 오버레이 생성
2825
+ const overlay = document.createElement('div');
2826
+ overlay.className = 'adstage-interstitial-overlay';
2827
+ overlay.style.cssText = `
2828
+ position: fixed;
2829
+ top: 0;
2830
+ left: 0;
2831
+ width: 100vw;
2832
+ height: 100vh;
2833
+ background: rgba(0, 0, 0, 0.8);
2834
+ display: flex;
2835
+ align-items: center;
2836
+ justify-content: center;
2837
+ z-index: 10000;
2838
+ animation: fadeIn 0.3s ease-out;
2839
+ `;
2840
+ // 전면광고 콘텐츠
2841
+ const adContent = document.createElement('div');
2842
+ adContent.className = 'adstage-interstitial-content';
2843
+ adContent.style.cssText = `
2844
+ position: relative;
2845
+ max-width: 90vw;
2846
+ max-height: 90vh;
2847
+ background: white;
2848
+ border-radius: 12px;
2849
+ padding: 24px;
2850
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
2851
+ animation: slideUp 0.3s ease-out;
2852
+ overflow: auto;
2853
+ `;
2854
+ // 닫기 버튼
2855
+ const closeButton = document.createElement('button');
2856
+ closeButton.innerHTML = '×';
2857
+ closeButton.style.cssText = `
2858
+ position: absolute;
2859
+ top: 12px;
2860
+ right: 12px;
2861
+ width: 32px;
2862
+ height: 32px;
2863
+ border: none;
2864
+ background: #f3f4f6;
2865
+ color: #6b7280;
2866
+ font-size: 20px;
2867
+ font-weight: bold;
2868
+ border-radius: 50%;
2869
+ cursor: pointer;
2870
+ display: flex;
2871
+ align-items: center;
2872
+ justify-content: center;
2873
+ transition: all 0.2s;
2874
+ `;
2875
+ closeButton.addEventListener('click', () => {
2876
+ this.closeInterstitial(overlay);
2877
+ });
2878
+ closeButton.addEventListener('mouseenter', () => {
2879
+ closeButton.style.backgroundColor = '#e5e7eb';
2880
+ closeButton.style.color = '#374151';
2881
+ });
2882
+ closeButton.addEventListener('mouseleave', () => {
2883
+ closeButton.style.backgroundColor = '#f3f4f6';
2884
+ closeButton.style.color = '#6b7280';
2885
+ });
2886
+ // 이미지 (있는 경우)
2887
+ if (advertisement.imageUrl) {
2888
+ const img = document.createElement('img');
2889
+ img.src = advertisement.imageUrl;
2890
+ img.alt = advertisement.title || 'Interstitial Advertisement';
2891
+ img.style.cssText = `
2892
+ width: 100%;
2893
+ max-height: 300px;
2894
+ object-fit: cover;
2895
+ border-radius: 8px;
2896
+ margin-bottom: 16px;
2897
+ `;
2898
+ adContent.appendChild(img);
2899
+ }
2900
+ // 제목
2901
+ if (advertisement.title) {
2902
+ const title = document.createElement('h2');
2903
+ title.textContent = advertisement.title;
2904
+ title.style.cssText = `
2905
+ margin: 0 0 12px 0;
2906
+ font-size: 24px;
2907
+ font-weight: 700;
2908
+ color: #111827;
2909
+ line-height: 1.3;
2910
+ `;
2911
+ adContent.appendChild(title);
2912
+ }
2913
+ // 설명
2914
+ if (advertisement.textContent) {
2915
+ const description = document.createElement('p');
2916
+ description.textContent = advertisement.textContent;
2917
+ description.style.cssText = `
2918
+ margin: 0 0 20px 0;
2919
+ font-size: 16px;
2920
+ color: #6b7280;
2921
+ line-height: 1.5;
2922
+ `;
2923
+ adContent.appendChild(description);
1941
2924
  }
1942
- const trackEventCallback = async (adId, slotId, eventType) => {
1943
- if (eventType === AdEventType.VIEWABLE) {
1944
- if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
1945
- if (this.debug) {
1946
- console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
1947
- }
1948
- return;
1949
- }
1950
- if (this.debug) {
1951
- console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
1952
- }
1953
- }
1954
- if (this.advertisementEventTracker) {
1955
- try {
1956
- if (this.debug) {
1957
- console.log(`🔄 Starting advertisement event tracking: ${eventType} for ad ${adId} in slot ${slotId}`);
1958
- }
1959
- await this.advertisementEventTracker.trackAdvertisementEvent(adId, slotId, eventType);
1960
- if (this.debug) {
1961
- console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
1962
- }
1963
- }
1964
- catch (error) {
1965
- if (this.debug) {
1966
- console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
1967
- }
1968
- }
1969
- }
1970
- else {
1971
- if (this.debug) {
1972
- console.warn(`⚠️ AdvertisementEventTracker not available for ${eventType} event`);
2925
+ // CTA 버튼 (링크가 있는 경우)
2926
+ if (advertisement.linkUrl) {
2927
+ const ctaButton = document.createElement('button');
2928
+ ctaButton.textContent = 'Get Started';
2929
+ ctaButton.style.cssText = `
2930
+ width: 100%;
2931
+ padding: 12px 24px;
2932
+ background: #3b82f6;
2933
+ color: white;
2934
+ border: none;
2935
+ border-radius: 8px;
2936
+ font-size: 16px;
2937
+ font-weight: 600;
2938
+ cursor: pointer;
2939
+ transition: background-color 0.2s;
2940
+ `;
2941
+ ctaButton.addEventListener('click', () => {
2942
+ if (this.advertisementEventTracker) {
2943
+ console.log(`Interstitial click tracked for ad: ${advertisement._id}`);
1973
2944
  }
2945
+ window.open(advertisement.linkUrl, '_blank');
2946
+ this.closeInterstitial(overlay);
2947
+ });
2948
+ ctaButton.addEventListener('mouseenter', () => {
2949
+ ctaButton.style.backgroundColor = '#2563eb';
2950
+ });
2951
+ ctaButton.addEventListener('mouseleave', () => {
2952
+ ctaButton.style.backgroundColor = '#3b82f6';
2953
+ });
2954
+ adContent.appendChild(ctaButton);
2955
+ }
2956
+ // ESC 키로 닫기
2957
+ const handleEscape = (event) => {
2958
+ if (event.key === 'Escape') {
2959
+ this.closeInterstitial(overlay);
2960
+ document.removeEventListener('keydown', handleEscape);
1974
2961
  }
1975
2962
  };
1976
- let sliderElement;
1977
- const optimizedSliderOptions = {
1978
- autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
1979
- ...slot.config,
1980
- optimizedHeight: slot.optimizedHeight,
1981
- aspectRatio: slot.aspectRatio
1982
- };
1983
- if (slot.adType === AdType.TEXT) {
1984
- sliderElement = TextTransitionManager.createTextTransitionContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
2963
+ document.addEventListener('keydown', handleEscape);
2964
+ // 오버레이 클릭으로 닫기
2965
+ overlay.addEventListener('click', (event) => {
2966
+ if (event.target === overlay) {
2967
+ this.closeInterstitial(overlay);
2968
+ }
2969
+ });
2970
+ // CSS 애니메이션 추가
2971
+ if (!document.getElementById('adstage-interstitial-styles')) {
2972
+ const styles = document.createElement('style');
2973
+ styles.id = 'adstage-interstitial-styles';
2974
+ styles.textContent = `
2975
+ @keyframes fadeIn {
2976
+ from { opacity: 0; }
2977
+ to { opacity: 1; }
2978
+ }
2979
+ @keyframes slideUp {
2980
+ from {
2981
+ opacity: 0;
2982
+ transform: translateY(20px);
2983
+ }
2984
+ to {
2985
+ opacity: 1;
2986
+ transform: translateY(0);
2987
+ }
2988
+ }
2989
+ `;
2990
+ document.head.appendChild(styles);
2991
+ }
2992
+ adContent.appendChild(closeButton);
2993
+ overlay.appendChild(adContent);
2994
+ document.body.appendChild(overlay);
2995
+ if (this.debug) {
2996
+ console.log(`🖼️ Interstitial ad rendered: ${advertisement._id}`);
2997
+ }
2998
+ }
2999
+ /**
3000
+ * 다중 전면광고 렌더링 - 현재는 단일 렌더링만 지원
3001
+ */
3002
+ async renderMultipleAds(slot, advertisements) {
3003
+ if (advertisements.length > 0) {
3004
+ await this.renderAdElement(slot, advertisements[0]);
1985
3005
  if (this.debug) {
1986
- console.log(`✨ Text transition created for TEXT slot: ${slot.id} with ${advertisements.length} ads`);
3006
+ console.log(`🖼️ Interstitial ad rendered (first of ${advertisements.length}): ${slot.id}`);
1987
3007
  }
1988
3008
  }
1989
3009
  else {
1990
- sliderElement = CarouselSliderManager.createSliderContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
1991
3010
  if (this.debug) {
1992
- console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads (optimized: ${slot.optimizedHeight || 'default'})`);
3011
+ console.warn(`⚠️ No interstitial advertisements available for slot: ${slot.id}`);
1993
3012
  }
1994
3013
  }
1995
- container.innerHTML = '';
1996
- container.appendChild(sliderElement);
3014
+ }
3015
+ /**
3016
+ * 전면광고 닫기
3017
+ */
3018
+ closeInterstitial(overlay) {
3019
+ overlay.style.animation = 'fadeOut 0.3s ease-out forwards';
3020
+ // CSS 애니메이션이 없으면 즉시 제거
3021
+ setTimeout(() => {
3022
+ if (overlay.parentNode) {
3023
+ overlay.parentNode.removeChild(overlay);
3024
+ }
3025
+ }, 300);
1997
3026
  if (this.debug) {
1998
- console.log(`� Slider uses manual VIEWABLE event tracking for ${advertisements.length} ads`);
3027
+ console.log('🖼️ Interstitial ad closed');
1999
3028
  }
2000
3029
  }
3030
+ }
3031
+
3032
+ /**
3033
+ * AdRenderer - 광고 렌더링 팩토리 클래스
3034
+ * 광고 타입별로 적절한 렌더러를 생성하고 관리
3035
+ */
3036
+ class AdRenderer {
3037
+ constructor(debug = false, advertisementEventTracker) {
3038
+ this.renderers = new Map();
3039
+ this.debug = debug;
3040
+ this.advertisementEventTracker = advertisementEventTracker || null;
3041
+ // 각 광고 타입별 렌더러 초기화
3042
+ this.initializeRenderers();
3043
+ }
2001
3044
  /**
2002
- * 광고 렌더링 (단일 광고용)
3045
+ * 광고 타입별 렌더러 초기화
2003
3046
  */
2004
- async renderAd(slot) {
2005
- if (!slot.advertisement) {
2006
- throw new Error('No advertisement to render');
3047
+ initializeRenderers() {
3048
+ this.renderers.set(AdType.BANNER, new BannerAdRenderer(this.debug, this.advertisementEventTracker));
3049
+ this.renderers.set(AdType.TEXT, new TextAdRenderer(this.debug, this.advertisementEventTracker));
3050
+ this.renderers.set(AdType.VIDEO, new VideoAdRenderer(this.debug, this.advertisementEventTracker));
3051
+ this.renderers.set(AdType.NATIVE, new NativeAdRenderer(this.debug, this.advertisementEventTracker));
3052
+ this.renderers.set(AdType.INTERSTITIAL, new InterstitialAdRenderer(this.debug, this.advertisementEventTracker));
3053
+ if (this.debug) {
3054
+ console.log(`🏭 AdRenderer factory initialized with ${this.renderers.size} renderers`);
2007
3055
  }
2008
- await this.renderAdElement(slot, slot.advertisement);
2009
- slot.isLoaded = true;
2010
3056
  }
2011
3057
  /**
2012
- * 광고 요소 렌더링 (기본 구현)
3058
+ * 광고 요소를 동기적으로 생성해서 반환 (크기 측정 등을 위한 helper)
2013
3059
  */
2014
- async renderAdElement(slot, ad) {
2015
- const container = document.getElementById(slot.containerId);
2016
- if (!container)
2017
- return;
3060
+ createAdElement(slot, advertisement) {
3061
+ const renderer = this.getRenderer(slot.adType);
3062
+ // 기본 광고 요소 생성
2018
3063
  const adElement = document.createElement('div');
2019
- adElement.className = 'adstage-ad';
2020
- const optimizedHeight = slot.optimizedHeight;
2021
- const containerElement = container.parentElement || container;
2022
- if (optimizedHeight) {
2023
- adElement.style.width = '100%';
2024
- adElement.style.height = String(optimizedHeight);
2025
- }
2026
- else {
2027
- const { width, height } = this.calculateAdSize(containerElement, slot.adType, slot.config || {}, { debug: this.debug }) ||
2028
- { width: '100%', height: '250px' };
2029
- adElement.style.width = width;
2030
- adElement.style.height = height;
2031
- }
3064
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
3065
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
3066
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
3067
+ // 광고 타입별 기본 컨테이너 설정
3068
+ const { width, height } = renderer.calculateAdSize(adElement, slot.config || {}, advertisement);
3069
+ adElement.style.width = width;
3070
+ adElement.style.height = height;
3071
+ adElement.style.display = 'block';
3072
+ // 간단한 내용 설정 (크기 측정용)
2032
3073
  switch (slot.adType) {
2033
3074
  case AdType.BANNER:
2034
- if (ad.imageUrl) {
2035
- await this.renderOptimizedBannerImage(adElement, ad, slot);
3075
+ if (advertisement.imageUrl) {
3076
+ const img = document.createElement('img');
3077
+ img.src = advertisement.imageUrl;
3078
+ img.style.width = '100%';
3079
+ img.style.height = '100%';
3080
+ img.style.objectFit = 'cover';
3081
+ adElement.appendChild(img);
2036
3082
  }
2037
3083
  break;
2038
- case AdType.TEXT: {
2039
- const textDiv = document.createElement('div');
2040
- textDiv.innerHTML = `
2041
- ${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
2042
- `;
2043
- adElement.appendChild(textDiv);
2044
- break;
2045
- }
2046
3084
  case AdType.VIDEO:
2047
- if (ad.videoUrl) {
3085
+ if (advertisement.videoUrl) {
2048
3086
  const video = document.createElement('video');
2049
- video.src = ad.videoUrl;
2050
- video.controls = true;
3087
+ video.src = advertisement.videoUrl;
2051
3088
  video.style.width = '100%';
2052
3089
  video.style.height = '100%';
2053
3090
  adElement.appendChild(video);
2054
3091
  }
2055
3092
  break;
3093
+ case AdType.TEXT:
3094
+ if (advertisement.textContent) {
3095
+ const textDiv = document.createElement('div');
3096
+ textDiv.textContent = advertisement.textContent || '';
3097
+ textDiv.style.padding = '8px';
3098
+ adElement.appendChild(textDiv);
3099
+ }
3100
+ break;
2056
3101
  default:
2057
- adElement.innerHTML = `<div>${ad.title}</div>`;
2058
- }
2059
- if (ad.linkUrl) {
2060
- adElement.style.cursor = 'pointer';
2061
- adElement.addEventListener('click', () => {
2062
- window.open(ad.linkUrl, '_blank');
2063
- });
3102
+ // 기본 placeholder
3103
+ adElement.style.border = '1px dashed #ccc';
3104
+ adElement.style.backgroundColor = '#f9f9f9';
3105
+ adElement.textContent = `${slot.adType} Ad`;
2064
3106
  }
2065
- container.innerHTML = '';
2066
- container.appendChild(adElement);
3107
+ return adElement;
2067
3108
  }
2068
3109
  /**
2069
- * Fallback 광고 렌더링 - 컨테이너 접기/생성
3110
+ * 광고 타입에 따른 렌더러 획득
2070
3111
  */
2071
- renderFallback(slot) {
2072
- const element = document.getElementById(slot.id);
2073
- if (element) {
2074
- const adstageContainers = [
2075
- element.querySelector('[data-adstage-container="true"]'),
2076
- element.closest('[data-adstage-container="true"]'),
2077
- element
2078
- ].filter(el => el && el.hasAttribute('data-adstage-container'));
2079
- const classBasedContainers = [
2080
- element.closest('.adstage-slot'),
2081
- element.closest('.adstage-banner'),
2082
- element.closest('.adstage-text'),
2083
- element.closest('.adstage-video'),
2084
- element.closest('.adstage-native'),
2085
- element.closest('.adstage-interstitial')
2086
- ].filter(Boolean);
2087
- const generalContainers = [
2088
- element.closest('[class*="ad"]'),
2089
- element.closest('[class*="banner"]'),
2090
- element.closest('[class*="container"]'),
2091
- element.closest('div[style*="height"]'),
2092
- element.closest('div[style*="min-height"]'),
2093
- element.parentElement
2094
- ].filter(Boolean);
2095
- const possibleContainers = [...adstageContainers, ...classBasedContainers, ...generalContainers];
2096
- const targetContainer = possibleContainers[0];
2097
- if (targetContainer) {
2098
- let containerType = 'unknown';
2099
- if (targetContainer.hasAttribute('data-adstage-container')) {
2100
- containerType = 'adstage-official';
2101
- }
2102
- else if (targetContainer.classList.contains('adstage-slot')) {
2103
- containerType = 'adstage-class';
2104
- }
2105
- else {
2106
- containerType = 'generic';
2107
- }
2108
- targetContainer.style.cssText += `
2109
- height: 0px !important;
2110
- min-height: 0px !important;
2111
- padding: 0px !important;
2112
- margin: 0px !important;
2113
- border: none !important;
2114
- overflow: hidden !important;
2115
- display: block !important;
2116
- `;
2117
- targetContainer.innerHTML = '';
2118
- targetContainer.setAttribute('data-adstage-empty', 'true');
2119
- if (this.debug) {
2120
- console.warn(`⚠️ Ad container collapsed (${containerType}): ${slot.id}`, targetContainer);
2121
- }
2122
- }
2123
- else {
2124
- this.createEmptyContainer(slot);
2125
- }
3112
+ getRenderer(adType) {
3113
+ const renderer = this.renderers.get(adType);
3114
+ if (!renderer) {
3115
+ throw new Error(`No renderer found for ad type: ${adType}`);
2126
3116
  }
2127
- slot.advertisement = undefined;
2128
- slot.isEmpty = true;
3117
+ return renderer;
3118
+ }
3119
+ /**
3120
+ * Placeholder(슬롯 컨테이너) 생성
3121
+ */
3122
+ createPlaceholder(container, slotId, type, options, config) {
3123
+ const renderer = this.getRenderer(type);
3124
+ renderer.createPlaceholder(container, slotId, options, config);
2129
3125
  }
2130
3126
  /**
2131
- * 컨테이너 생성 (컨테이너를 찾지 못한 경우)
3127
+ * 배너 광고를 위한 컨테이너 최적화 (배너 전용)
2132
3128
  */
2133
- createEmptyContainer(slot) {
2134
- const originalContainer = document.getElementById(slot.containerId);
2135
- if (originalContainer) {
2136
- originalContainer.innerHTML = '';
2137
- const emptyElement = document.createElement('div');
2138
- emptyElement.id = slot.id;
2139
- emptyElement.className = 'adstage-slot adstage-empty';
2140
- emptyElement.setAttribute('data-adstage-container', 'true');
2141
- emptyElement.setAttribute('data-adstage-empty', 'true');
2142
- emptyElement.setAttribute('data-adstage-slot', slot.id);
2143
- emptyElement.style.cssText = `
2144
- height: 0px !important;
2145
- min-height: 0px !important;
2146
- padding: 0px !important;
2147
- margin: 0px !important;
2148
- border: none !important;
2149
- overflow: hidden !important;
2150
- display: block !important;
2151
- `;
2152
- originalContainer.appendChild(emptyElement);
3129
+ async optimizeContainerForBannerAds(slot, advertisements) {
3130
+ if (slot.adType !== AdType.BANNER) {
2153
3131
  if (this.debug) {
2154
- console.warn(`⚠️ Created empty AdStage container: ${slot.id}`);
3132
+ console.warn(`⚠️ Container optimization is only supported for BANNER ads, got: ${slot.adType}`);
2155
3133
  }
3134
+ return;
2156
3135
  }
3136
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3137
+ await bannerRenderer.optimizeContainerForBannerAds(slot, advertisements);
2157
3138
  }
2158
3139
  /**
2159
- * 광고 타입별 기본 높이 반환
3140
+ * 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
2160
3141
  */
2161
- getDefaultHeightForAdType(type) {
2162
- switch (type) {
2163
- case AdType.BANNER:
2164
- return '250px'; // 일반 배너
2165
- case AdType.TEXT:
2166
- return '60px'; // 텍스트는 좀 더 작게
2167
- case AdType.VIDEO:
2168
- return '360px'; // 비디오는 16:9 비율 고려
2169
- case AdType.NATIVE:
2170
- return '200px'; // 네이티브는 중간 크기
2171
- case AdType.INTERSTITIAL:
2172
- return '400px'; // 전면광고는 크게
2173
- default:
2174
- return '250px';
2175
- }
3142
+ async renderAdSlider(slot, advertisements) {
3143
+ const renderer = this.getRenderer(slot.adType);
3144
+ await renderer.renderMultipleAds(slot, advertisements);
2176
3145
  }
2177
3146
  /**
2178
- * 컨테이너와 광고 타입에 따른 스마트한 크기 계산
3147
+ * 광고 렌더링 (단일 광고용)
2179
3148
  */
2180
- calculateAdSize(container, type, options, config) {
2181
- // 사용자가 명시적으로 크기를 지정한 경우
2182
- const explicitWidth = options.width;
2183
- const explicitHeight = options.height;
2184
- // 너비 처리
2185
- let width;
2186
- if (typeof explicitWidth === 'number') {
2187
- width = `${explicitWidth}px`;
2188
- }
2189
- else if (typeof explicitWidth === 'string') {
2190
- width = explicitWidth;
2191
- }
2192
- else {
2193
- width = '100%'; // 기본값은 100%
2194
- }
2195
- // 높이 처리 - 핵심 로직
2196
- let height;
2197
- if (typeof explicitHeight === 'number') {
2198
- height = `${explicitHeight}px`;
2199
- }
2200
- else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
2201
- // 명시적인 크기 문자열 (예: '200px', '50vh' 등)
2202
- height = explicitHeight;
2203
- }
2204
- else {
2205
- // 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
2206
- const containerHeight = this.getContainerHeight(container);
2207
- if (containerHeight > 0) {
2208
- // 컨테이너에 높이가 있으면 100% 사용
2209
- height = '100%';
2210
- if (config?.debug) {
2211
- console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
2212
- }
2213
- }
2214
- else {
2215
- // 컨테이너에 높이가 없으면 타입별 기본값 사용 (나중에 동적 조정됨)
2216
- height = this.getDefaultHeightForAdType(type);
2217
- if (config?.debug) {
2218
- console.log(`📏 Using default height ${height} (will be optimized for ${type})`);
2219
- }
2220
- }
3149
+ async renderAd(slot) {
3150
+ if (!slot.advertisement) {
3151
+ throw new Error('No advertisement to render');
2221
3152
  }
2222
- return { width, height };
3153
+ const renderer = this.getRenderer(slot.adType);
3154
+ await renderer.renderAdElement(slot, slot.advertisement);
3155
+ slot.isLoaded = true;
2223
3156
  }
2224
3157
  /**
2225
- * 컨테이너의 실제 높이 계산
3158
+ * 광고 요소 렌더링 (기본 구현) - 호환성을 위해 유지
2226
3159
  */
2227
- getContainerHeight(container) {
2228
- // 현재 계산된 스타일에서 높이 확인
2229
- const computedStyle = window.getComputedStyle(container);
2230
- const height = parseFloat(computedStyle.height);
2231
- // height가 auto이거나 0이면 다른 방법들 시도
2232
- if (!height || height === 0) {
2233
- // min-height 확인
2234
- const minHeight = parseFloat(computedStyle.minHeight);
2235
- if (minHeight > 0)
2236
- return minHeight;
2237
- // CSS로 설정된 고정 높이 확인
2238
- if (container.style.height && container.style.height !== 'auto') {
2239
- const styleHeight = parseFloat(container.style.height);
2240
- if (styleHeight > 0)
2241
- return styleHeight;
2242
- }
2243
- // 속성으로 설정된 높이 확인
2244
- const heightAttr = container.getAttribute('height');
2245
- if (heightAttr) {
2246
- const attrHeight = parseFloat(heightAttr);
2247
- if (attrHeight > 0)
2248
- return attrHeight;
2249
- }
2250
- }
2251
- return height || 0;
3160
+ async renderAdElement(slot, ad) {
3161
+ const renderer = this.getRenderer(slot.adType);
3162
+ await renderer.renderAdElement(slot, ad);
3163
+ }
3164
+ /**
3165
+ * Fallback 광고 렌더링 - 컨테이너 접기/생성
3166
+ */
3167
+ renderFallback(slot) {
3168
+ const renderer = this.getRenderer(slot.adType);
3169
+ renderer.renderFallback(slot);
3170
+ }
3171
+ /**
3172
+ * 광고 타입별 기본 높이 반환
3173
+ */
3174
+ getDefaultHeightForAdType(type) {
3175
+ const renderer = this.getRenderer(type);
3176
+ return renderer.getDefaultHeight();
3177
+ }
3178
+ /**
3179
+ * 컨테이너와 광고 타입에 따른 스마트한 크기 계산
3180
+ */
3181
+ calculateAdSize(container, type, options, config) {
3182
+ const renderer = this.getRenderer(type);
3183
+ return renderer.calculateAdSize(container, options, config);
2252
3184
  }
2253
3185
  /**
2254
- * 이미지 로드 및 실제 크기 획득 (Promise 기반)
3186
+ * 이미지 로드 및 실제 크기 획득 (배너 전용)
2255
3187
  */
2256
3188
  loadImageDimensions(imageUrl) {
2257
3189
  return new Promise((resolve, reject) => {
@@ -2266,118 +3198,36 @@ class AdRenderer {
2266
3198
  });
2267
3199
  }
2268
3200
  /**
2269
- * 이미지와 컨테이너 비율을 고려한 최적화 스타일 적용
3201
+ * 배너 이미지 최적화 렌더링 (배너 전용)
2270
3202
  */
2271
- applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio) {
2272
- const ratio = imageAspectRatio / containerAspectRatio;
2273
- if (Math.abs(ratio - 1) < 0.1) {
2274
- // 비율이 거의 같으면 cover 사용
2275
- img.style.objectFit = 'cover';
2276
- img.style.objectPosition = 'center';
2277
- }
2278
- else if (ratio > 1.3) {
2279
- // 이미지가 훨씬 가로형이면 contain으로 전체 보이기
2280
- img.style.objectFit = 'contain';
2281
- img.style.objectPosition = 'center';
2282
- img.style.backgroundColor = '#f0f0f0'; // 빈 공간 배경색
2283
- }
2284
- else if (ratio < 0.7) {
2285
- // 이미지가 훨씬 세로형이면 cover로 채우기
2286
- img.style.objectFit = 'cover';
2287
- img.style.objectPosition = 'center';
2288
- }
2289
- else {
2290
- // 적당한 차이면 스마트 cover
2291
- img.style.objectFit = 'cover';
2292
- img.style.objectPosition = 'center';
2293
- }
2294
- if (this.debug) {
2295
- console.log(`🎨 Image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
3203
+ async renderOptimizedBannerImage(container, advertisement, slot) {
3204
+ if (slot.adType !== AdType.BANNER) {
3205
+ throw new Error('renderOptimizedBannerImage is only supported for BANNER ads');
2296
3206
  }
3207
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3208
+ return await bannerRenderer.renderOptimizedBannerImage(container, advertisement, slot);
2297
3209
  }
2298
3210
  /**
2299
- * 배너 이미지 최적화 렌더링
2300
- * 이미지 실제 크기를 로드한 후 컨테이너와 비율을 맞춤
3211
+ * 여러 광고의 최적 컨테이너 크기 계산 (배너 전용)
2301
3212
  */
2302
- async renderOptimizedBannerImage(container, advertisement, slot) {
2303
- const img = document.createElement('img');
2304
- // 기본 스타일 설정
2305
- img.style.width = '100%';
2306
- img.style.height = '100%';
2307
- img.style.display = 'block';
2308
- img.style.borderRadius = '8px';
2309
- img.alt = advertisement.title || 'Advertisement';
2310
- // 이미지 로딩 상태 표시
2311
- container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #999;">Loading...</div>';
2312
- try {
2313
- // 이미지 URL 체크
2314
- if (!advertisement.imageUrl) {
2315
- throw new Error('Image URL is not provided');
2316
- }
2317
- // 🆕 이미지 실제 크기 로드
2318
- const imageDimensions = await this.loadImageDimensions(advertisement.imageUrl);
2319
- // 컨테이너 크기 정보
2320
- const containerRect = container.getBoundingClientRect();
2321
- const containerWidth = containerRect.width;
2322
- const containerHeight = containerRect.height;
2323
- if (this.debug) {
2324
- console.log(`📸 Image dimensions: ${imageDimensions.width}x${imageDimensions.height}`);
2325
- console.log(`📦 Container dimensions: ${containerWidth}x${containerHeight}`);
2326
- }
2327
- // 비율 계산
2328
- const imageAspectRatio = imageDimensions.width / imageDimensions.height;
2329
- const containerAspectRatio = containerWidth / containerHeight;
2330
- // 🆕 스마트 스타일 적용
2331
- this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
2332
- // 이미지 설정 및 추가
2333
- img.src = advertisement.imageUrl;
2334
- // 컨테이너 클리어 후 이미지 추가
2335
- container.innerHTML = '';
2336
- container.appendChild(img);
2337
- // 클릭 이벤트 등록
2338
- if (advertisement.linkUrl) {
2339
- img.style.cursor = 'pointer';
2340
- img.addEventListener('click', () => {
2341
- if (this.advertisementEventTracker) {
2342
- // AdvertisementEventTracker의 실제 메소드명을 확인해야 함
2343
- console.log(`Click tracked for ad: ${advertisement._id}`);
2344
- }
2345
- window.open(advertisement.linkUrl, '_blank');
2346
- });
2347
- }
2348
- if (this.debug) {
2349
- console.log(`✅ Optimized banner image rendered for ad: ${advertisement._id}`);
2350
- }
2351
- return img;
2352
- }
2353
- catch (error) {
2354
- console.error('❌ Failed to load optimized banner image:', error);
2355
- // 폴백: 일반 이미지 렌더링
2356
- if (advertisement.imageUrl) {
2357
- img.src = advertisement.imageUrl;
2358
- img.style.objectFit = 'cover';
2359
- img.style.objectPosition = 'center';
2360
- container.innerHTML = '';
2361
- container.appendChild(img);
2362
- if (advertisement.linkUrl) {
2363
- img.style.cursor = 'pointer';
2364
- img.addEventListener('click', () => {
2365
- if (this.advertisementEventTracker) {
2366
- console.log(`Click tracked for ad: ${advertisement._id}`);
2367
- }
2368
- window.open(advertisement.linkUrl, '_blank');
2369
- });
2370
- }
2371
- }
2372
- return img;
3213
+ async calculateOptimalContainerSize(advertisements, containerWidth, adType) {
3214
+ if (adType !== AdType.BANNER) {
3215
+ const renderer = this.getRenderer(adType);
3216
+ return {
3217
+ width: '100%',
3218
+ height: renderer.getDefaultHeight(),
3219
+ aspectRatio: 16 / 9
3220
+ };
2373
3221
  }
3222
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3223
+ return await bannerRenderer.calculateOptimalContainerSize(advertisements, containerWidth);
2374
3224
  }
2375
3225
  /**
2376
3226
  * 디버그 로그 출력
2377
3227
  */
2378
3228
  log(message, ...args) {
2379
3229
  if (this.debug) {
2380
- console.log(message, ...args);
3230
+ console.log(`[AdRenderer] ${message}`, ...args);
2381
3231
  }
2382
3232
  }
2383
3233
  }
@@ -2395,6 +3245,8 @@ class AdsModule {
2395
3245
  this.advertisementEventTracker = null;
2396
3246
  // 렌더링 관련
2397
3247
  this.adRenderer = null;
3248
+ // DOM 변화 감지를 위한 MutationObserver
3249
+ this.mutationObserver = null;
2398
3250
  }
2399
3251
  /**
2400
3252
  * Ads 모듈 초기화 (동기)
@@ -2405,9 +3257,11 @@ class AdsModule {
2405
3257
  this.advertisementEventTracker = new AdvertisementEventTracker(endpoints.getBaseUrl(), config.apiKey, config.debug || false, this.slots);
2406
3258
  // AdRenderer 초기화
2407
3259
  this.adRenderer = new AdRenderer(config.debug || false, this.advertisementEventTracker);
3260
+ // DOM 변화 감지를 위한 MutationObserver 설정
3261
+ this.setupAutoCleanup();
2408
3262
  this._isReady = true;
2409
3263
  if (config.debug) {
2410
- console.log('🎯 Ads module initialized (sync mode)');
3264
+ console.log('🎯 Ads module initialized (sync mode) with auto-cleanup');
2411
3265
  }
2412
3266
  }
2413
3267
  /**
@@ -2461,16 +3315,24 @@ class AdsModule {
2461
3315
  return this.createAd(containerId, AdType.TEXT, adstageOptions);
2462
3316
  }
2463
3317
  /**
2464
- * 비디오 광고 생성 (동기)
3318
+ * 비디오 광고 생성 (동기) - 단일 비디오만 지원
2465
3319
  */
2466
3320
  video(containerId, options) {
2467
3321
  this.ensureReady();
2468
3322
  const adstageOptions = {
2469
3323
  width: options?.width || 640,
2470
3324
  height: options?.height || 360,
2471
- autoplay: options?.autoplay || false,
2472
- muted: options?.muted || true,
2473
- onClick: options?.onClick
3325
+ autoplay: options?.autoplay !== undefined ? options.autoplay : true, // 기본값 true (사용자 요구사항)
3326
+ muted: options?.muted !== undefined ? options.muted : true, // 기본값 true
3327
+ loop: options?.loop !== undefined ? options.loop : true, // 기본값 true (사용자 요구사항)
3328
+ playsinline: options?.playsinline !== false, // 기본값 true
3329
+ controls: options?.controls !== undefined ? options.controls : false, // 기본값 false (사용자 요구사항)
3330
+ hideControls: options?.hideControls || false,
3331
+ customControls: options?.customControls,
3332
+ autoSlide: false, // 비디오는 슬라이드 비활성화
3333
+ maxAds: 1, // 하나의 비디오만 가져오기
3334
+ onClick: options?.onClick,
3335
+ ...(options?.adId && { adId: options.adId }) // 특정 비디오 ID가 있으면 사용
2474
3336
  };
2475
3337
  return this.createAd(containerId, AdType.VIDEO, adstageOptions);
2476
3338
  }
@@ -2548,12 +3410,58 @@ class AdsModule {
2548
3410
  if (!this._config?.apiKey) {
2549
3411
  throw new Error('API key not configured');
2550
3412
  }
2551
- const container = document.getElementById(containerId);
2552
- if (!container) {
2553
- throw new Error(`Container not found: ${containerId}`);
2554
- }
2555
- // 고유한 슬롯 ID 생성
3413
+ // 즉시 슬롯 ID 생성 (개발자에게 바로 반환)
2556
3414
  const slotId = `adstage-${type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3415
+ // 비동기로 광고 생성 처리 (디바운싱 및 재시도 로직 포함)
3416
+ this.createAdWithRetry(containerId, type, options, slotId, 0);
3417
+ return slotId;
3418
+ }
3419
+ async createAdWithRetry(containerId, type, options, slotId, attempt) {
3420
+ const maxAttempts = 5;
3421
+ const delays = [0, 50, 100, 200, 500]; // 점진적 지연
3422
+ let container = null;
3423
+ let containerIdString;
3424
+ // HTMLElement인지 string인지 구분
3425
+ if (typeof containerId === 'string') {
3426
+ // string인 경우 DOM에서 찾기
3427
+ containerIdString = containerId;
3428
+ container = document.getElementById(containerId);
3429
+ if (!container) {
3430
+ if (attempt < maxAttempts - 1) {
3431
+ if (this._config?.debug) {
3432
+ console.warn(`Container not found: ${containerId}. Retrying in ${delays[attempt + 1]}ms... (attempt ${attempt + 1}/${maxAttempts})`);
3433
+ }
3434
+ setTimeout(() => {
3435
+ this.createAdWithRetry(containerId, type, options, slotId, attempt + 1);
3436
+ }, delays[attempt + 1]);
3437
+ return;
3438
+ }
3439
+ else {
3440
+ console.error(`Container not found after ${maxAttempts} attempts: ${containerId}`);
3441
+ return;
3442
+ }
3443
+ }
3444
+ }
3445
+ else {
3446
+ // HTMLElement인 경우 직접 사용
3447
+ container = containerId;
3448
+ containerIdString = container.id || `auto-${slotId}`;
3449
+ // ID가 없으면 자동 생성
3450
+ if (!container.id) {
3451
+ container.id = containerIdString;
3452
+ }
3453
+ }
3454
+ // 컨테이너를 찾았으면 광고 생성
3455
+ try {
3456
+ this.createAdInternal(containerIdString, type, options, slotId, container);
3457
+ }
3458
+ catch (error) {
3459
+ console.error('광고 생성 중 오류 발생:', error);
3460
+ }
3461
+ }
3462
+ createAdInternal(containerId, type, options, slotId, container) {
3463
+ // 컨테이너에 슬롯 ID 속성 추가 (MutationObserver가 감지할 수 있도록)
3464
+ container.setAttribute('data-adstage-slot-id', slotId);
2557
3465
  // 즉시 placeholder 생성 (AdRenderer 위임)
2558
3466
  this.adRenderer?.createPlaceholder(container, slotId, type, options, this._config);
2559
3467
  // 광고 슬롯 정보 저장
@@ -2562,7 +3470,7 @@ class AdsModule {
2562
3470
  containerId,
2563
3471
  adType: type,
2564
3472
  width: options.width || '100%',
2565
- height: options.height || 250,
3473
+ height: options.height || (type === AdType.TEXT ? 'auto' : 250), // 텍스트 광고는 콘텐츠 높이에 맞춤
2566
3474
  isLoaded: false,
2567
3475
  isVisible: false,
2568
3476
  refreshRate: 0,
@@ -2583,7 +3491,6 @@ class AdsModule {
2583
3491
  if (this.advertisementEventTracker && this._config?.debug) {
2584
3492
  console.log(`📊 Advertisement event tracking enabled for slot: ${slotId}`);
2585
3493
  }
2586
- return slotId;
2587
3494
  }
2588
3495
  /**
2589
3496
  * 백그라운드에서 광고 콘텐츠 로드
@@ -2764,6 +3671,98 @@ class AdsModule {
2764
3671
  throw new Error('Ads module not initialized. Call AdStage.init() first.');
2765
3672
  }
2766
3673
  }
3674
+ /**
3675
+ * DOM 변화 감지를 통한 자동 정리 설정
3676
+ */
3677
+ setupAutoCleanup() {
3678
+ // 브라우저 환경에서만 실행
3679
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
3680
+ return;
3681
+ }
3682
+ // 기존 observer가 있으면 해제
3683
+ if (this.mutationObserver) {
3684
+ this.mutationObserver.disconnect();
3685
+ }
3686
+ // 새로운 MutationObserver 생성
3687
+ this.mutationObserver = new MutationObserver((mutations) => {
3688
+ mutations.forEach((mutation) => {
3689
+ // 제거된 노드들 확인
3690
+ mutation.removedNodes.forEach((node) => {
3691
+ if (node.nodeType === Node.ELEMENT_NODE) {
3692
+ this.handleRemovedElement(node);
3693
+ }
3694
+ });
3695
+ });
3696
+ });
3697
+ // document 전체를 관찰 (childList와 subtree 옵션 사용)
3698
+ this.mutationObserver.observe(document.body, {
3699
+ childList: true,
3700
+ subtree: true
3701
+ });
3702
+ if (this._config?.debug) {
3703
+ console.log('🔍 Auto-cleanup MutationObserver enabled');
3704
+ }
3705
+ }
3706
+ /**
3707
+ * 제거된 요소에서 광고 슬롯 정리
3708
+ */
3709
+ handleRemovedElement(element) {
3710
+ // 제거된 요소가 광고 컨테이너인지 확인
3711
+ const slotId = element.getAttribute('data-adstage-slot-id');
3712
+ if (slotId) {
3713
+ this.autoDestroy(slotId);
3714
+ return;
3715
+ }
3716
+ // 제거된 요소의 하위에 광고 컨테이너가 있는지 확인
3717
+ const adContainers = element.querySelectorAll('[data-adstage-slot-id]');
3718
+ adContainers.forEach((container) => {
3719
+ const containerSlotId = container.getAttribute('data-adstage-slot-id');
3720
+ if (containerSlotId) {
3721
+ this.autoDestroy(containerSlotId);
3722
+ }
3723
+ });
3724
+ }
3725
+ /**
3726
+ * 자동 정리 (로그 없이 조용히 정리)
3727
+ */
3728
+ autoDestroy(slotId) {
3729
+ const slot = this.slots.get(slotId);
3730
+ if (slot) {
3731
+ try {
3732
+ // 슬롯 정리 (로그 출력 최소화)
3733
+ this.slots.delete(slotId);
3734
+ if (this._config?.debug) {
3735
+ console.log(`🧹 Auto-cleanup: slot ${slotId} removed`);
3736
+ }
3737
+ }
3738
+ catch (error) {
3739
+ if (this._config?.debug) {
3740
+ console.warn(`Auto-cleanup failed for slot ${slotId}:`, error);
3741
+ }
3742
+ }
3743
+ }
3744
+ }
3745
+ /**
3746
+ * 모듈 종료 시 정리
3747
+ */
3748
+ destroyModule() {
3749
+ const debugMode = this._config?.debug;
3750
+ // MutationObserver 해제
3751
+ if (this.mutationObserver) {
3752
+ this.mutationObserver.disconnect();
3753
+ this.mutationObserver = null;
3754
+ }
3755
+ // 모든 슬롯 정리
3756
+ this.slots.clear();
3757
+ // 다른 리소스들 정리
3758
+ this.advertisementEventTracker = null;
3759
+ this.adRenderer = null;
3760
+ this._isReady = false;
3761
+ this._config = null;
3762
+ if (debugMode) {
3763
+ console.log('🗑️ Ads module destroyed');
3764
+ }
3765
+ }
2767
3766
  }
2768
3767
 
2769
3768
  /**
@@ -2785,10 +3784,6 @@ class ConfigModule {
2785
3784
  timeout: 30000,
2786
3785
  debug: false,
2787
3786
  modules: ['ads', 'events', 'config'],
2788
- validateOnInit: false,
2789
- fallbackMode: true,
2790
- offlineMode: false,
2791
- productionMode: false,
2792
3787
  ...config
2793
3788
  };
2794
3789
  // 사용자가 baseUrl을 제공한 경우 endpoints에 설정
@@ -2800,7 +3795,7 @@ class ConfigModule {
2800
3795
  console.log('✅ Config module initialized (sync mode)', {
2801
3796
  modules: this._config.modules,
2802
3797
  endpoint: endpoints.getBaseUrl(),
2803
- mode: config.productionMode ? 'production' : 'development'
3798
+ mode: 'development'
2804
3799
  });
2805
3800
  }
2806
3801
  }
@@ -2986,10 +3981,6 @@ class AdStage {
2986
3981
  timeout: 30000,
2987
3982
  debug: false,
2988
3983
  modules: ['ads', 'events', 'config'],
2989
- validateOnInit: false,
2990
- fallbackMode: true,
2991
- offlineMode: false,
2992
- productionMode: false,
2993
3984
  ...config
2994
3985
  };
2995
3986
  // 🔧 baseUrl이 설정된 경우 전역 endpoints 객체 업데이트
@@ -3013,7 +4004,7 @@ class AdStage {
3013
4004
  version: '2.0.0',
3014
4005
  modules: enabledModules,
3015
4006
  apiKey: config.apiKey.substring(0, 8) + '...',
3016
- mode: config.productionMode ? 'production' : 'development'
4007
+ mode: 'development'
3017
4008
  });
3018
4009
  }
3019
4010
  }
@@ -3060,6 +4051,34 @@ class AdStage {
3060
4051
  }
3061
4052
  }
3062
4053
  }
4054
+ /**
4055
+ * 디버그용 메서드들
4056
+ */
4057
+ AdStage.debug = {
4058
+ /**
4059
+ * 모든 viewable 추적 데이터 초기화
4060
+ */
4061
+ clearAllViewable: () => {
4062
+ ViewableEventTracker.clear();
4063
+ console.log('✅ AdStage Debug: 모든 viewable 추적 데이터 초기화됨');
4064
+ },
4065
+ /**
4066
+ * 특정 광고의 viewable 추적 초기화
4067
+ */
4068
+ clearAdViewable: (adId, slotId) => {
4069
+ ViewableEventTracker.clearAdViewable(adId, slotId);
4070
+ console.log(`✅ AdStage Debug: 광고 ${adId}(${slotId})의 viewable 추적 초기화됨`);
4071
+ },
4072
+ /**
4073
+ * 현재 추적 중인 viewable 상태 확인
4074
+ */
4075
+ getViewableStatus: () => {
4076
+ console.log('📊 AdStage Debug: 현재 viewable 추적 상태', {
4077
+ trackedCount: ViewableEventTracker.viewableTracker.size,
4078
+ trackedItems: Array.from(ViewableEventTracker.viewableTracker)
4079
+ });
4080
+ }
4081
+ };
3063
4082
 
3064
4083
  const AdStageContext = react.createContext(null);
3065
4084
  function AdStageProvider({ children, config }) {
@@ -3146,6 +4165,10 @@ function useAdStageInstance() {
3146
4165
  // 버전 정보
3147
4166
  const SDK_VERSION = '2.0.0';
3148
4167
  const SUPPORTED_MODULES = ['ads', 'events', 'config'];
4168
+ // 브라우저 환경에서 전역 객체로 노출 (디버깅용)
4169
+ if (typeof window !== 'undefined') {
4170
+ window.AdStage = AdStage;
4171
+ }
3149
4172
 
3150
4173
  exports.AdStage = AdStage;
3151
4174
  exports.AdStageProvider = AdStageProvider;