@adstage/web-sdk 2.5.3 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +59 -0
  2. package/dist/index.cjs.js +2073 -1045
  3. package/dist/index.d.ts +55 -12
  4. package/dist/index.esm.js +2073 -1045
  5. package/dist/index.standalone.js +2073 -1045
  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 +90 -12
  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
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 네이티브 광고 전용 렌더러
3
+ */
4
+
5
+ import { AdSlot, Advertisement, AdType } from '../../../types/advertisement';
6
+ import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
7
+ import { BaseAdRenderer } from './base-ad-renderer';
8
+
9
+ export class NativeAdRenderer extends BaseAdRenderer {
10
+ constructor(debug: boolean = false, advertisementEventTracker?: AdvertisementEventTracker | null) {
11
+ super(AdType.NATIVE, debug, advertisementEventTracker);
12
+ }
13
+
14
+ /**
15
+ * 네이티브 광고 기본 높이
16
+ */
17
+ getDefaultHeight(): string {
18
+ return '200px';
19
+ }
20
+
21
+ /**
22
+ * 단일 네이티브 광고 렌더링
23
+ */
24
+ async renderAdElement(slot: AdSlot, advertisement: Advertisement): Promise<void> {
25
+ const container = document.getElementById(slot.containerId);
26
+ if (!container) return;
27
+
28
+ const adElement = document.createElement('div');
29
+ adElement.className = 'adstage-ad adstage-native-ad';
30
+ adElement.style.cssText = `
31
+ width: 100%;
32
+ height: 100%;
33
+ display: flex;
34
+ flex-direction: column;
35
+ padding: 16px;
36
+ background: #fff;
37
+ border: 1px solid #e5e7eb;
38
+ border-radius: 8px;
39
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
40
+ `;
41
+
42
+ // 제목
43
+ if (advertisement.title) {
44
+ const title = document.createElement('h3');
45
+ title.textContent = advertisement.title;
46
+ title.style.cssText = `
47
+ margin: 0 0 8px 0;
48
+ font-size: 16px;
49
+ font-weight: 600;
50
+ color: #111827;
51
+ line-height: 1.3;
52
+ `;
53
+ adElement.appendChild(title);
54
+ }
55
+
56
+ // 설명
57
+ if (advertisement.textContent) {
58
+ const description = document.createElement('p');
59
+ description.textContent = advertisement.textContent;
60
+ description.style.cssText = `
61
+ margin: 0 0 12px 0;
62
+ font-size: 14px;
63
+ color: #6b7280;
64
+ line-height: 1.4;
65
+ flex: 1;
66
+ `;
67
+ adElement.appendChild(description);
68
+ }
69
+
70
+ // 이미지 (있는 경우)
71
+ if (advertisement.imageUrl) {
72
+ const imageContainer = document.createElement('div');
73
+ imageContainer.style.cssText = `
74
+ width: 100%;
75
+ height: 120px;
76
+ margin-bottom: 12px;
77
+ border-radius: 6px;
78
+ overflow: hidden;
79
+ background: #f3f4f6;
80
+ `;
81
+
82
+ const img = document.createElement('img');
83
+ img.src = advertisement.imageUrl;
84
+ img.alt = advertisement.title || 'Native Advertisement';
85
+ img.style.cssText = `
86
+ width: 100%;
87
+ height: 100%;
88
+ object-fit: cover;
89
+ `;
90
+
91
+ imageContainer.appendChild(img);
92
+ adElement.appendChild(imageContainer);
93
+ }
94
+
95
+ // CTA 버튼 (링크가 있는 경우)
96
+ if (advertisement.linkUrl) {
97
+ const ctaButton = document.createElement('button');
98
+ ctaButton.textContent = 'Learn More';
99
+ ctaButton.style.cssText = `
100
+ padding: 8px 16px;
101
+ background: #3b82f6;
102
+ color: white;
103
+ border: none;
104
+ border-radius: 6px;
105
+ font-size: 14px;
106
+ font-weight: 500;
107
+ cursor: pointer;
108
+ align-self: flex-start;
109
+ transition: background-color 0.2s;
110
+ `;
111
+
112
+ ctaButton.addEventListener('click', () => {
113
+ if (this.advertisementEventTracker) {
114
+ console.log(`Native click tracked for ad: ${advertisement._id}`);
115
+ }
116
+ window.open(advertisement.linkUrl!, '_blank');
117
+ });
118
+
119
+ ctaButton.addEventListener('mouseenter', () => {
120
+ ctaButton.style.backgroundColor = '#2563eb';
121
+ });
122
+
123
+ ctaButton.addEventListener('mouseleave', () => {
124
+ ctaButton.style.backgroundColor = '#3b82f6';
125
+ });
126
+
127
+ adElement.appendChild(ctaButton);
128
+ }
129
+
130
+ container.innerHTML = '';
131
+ container.appendChild(adElement);
132
+
133
+ if (this.debug) {
134
+ console.log(`🏠 Single native ad rendered: ${advertisement._id}`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * 다중 네이티브 광고 렌더링 - 현재는 단일 렌더링만 지원
140
+ */
141
+ async renderMultipleAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
142
+ if (advertisements.length > 0) {
143
+ await this.renderAdElement(slot, advertisements[0]);
144
+
145
+ if (this.debug) {
146
+ console.log(`🏠 Native ad rendered (first of ${advertisements.length}): ${slot.id}`);
147
+ }
148
+ } else {
149
+ if (this.debug) {
150
+ console.warn(`⚠️ No native advertisements available for slot: ${slot.id}`);
151
+ }
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 텍스트 광고 전용 렌더러
3
+ */
4
+
5
+ import { AdSlot, Advertisement, AdType } from '../../../types/advertisement';
6
+ import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
7
+ import { TextTransitionManager } from '../../../managers/ads/text-transition-manager';
8
+ import { BaseAdRenderer } from './base-ad-renderer';
9
+ import { TextAdUtils } from '../../../utils/text-ad-utils';
10
+ import { AdClickHandler } from '../../../utils/ad-click-handler';
11
+
12
+ export class TextAdRenderer extends BaseAdRenderer {
13
+ constructor(debug: boolean = false, advertisementEventTracker?: AdvertisementEventTracker | null) {
14
+ super(AdType.TEXT, debug, advertisementEventTracker);
15
+ }
16
+
17
+ /**
18
+ * 텍스트 광고 기본 높이
19
+ */
20
+ getDefaultHeight(): string {
21
+ return '60px';
22
+ }
23
+
24
+ /**
25
+ * 단일 텍스트 광고 렌더링
26
+ */
27
+ async renderAdElement(slot: AdSlot, advertisement: Advertisement): Promise<void> {
28
+ const container = document.getElementById(slot.containerId);
29
+ if (!container) return;
30
+
31
+ const adElement = document.createElement('div');
32
+ adElement.className = 'adstage-ad adstage-text-ad';
33
+
34
+ const optimizedHeight = (slot as any).optimizedHeight;
35
+ const containerElement = container.parentElement || container;
36
+
37
+ if (optimizedHeight) {
38
+ adElement.style.width = '100%';
39
+ adElement.style.height = String(optimizedHeight);
40
+ } else {
41
+ const config = slot.config as any;
42
+ const options = {
43
+ width: config?.width,
44
+ height: config?.height
45
+ };
46
+ const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
47
+ adElement.style.width = width;
48
+ adElement.style.height = height;
49
+ }
50
+
51
+ // 텍스트 콘텐츠 렌더링
52
+ const textDiv = document.createElement('div');
53
+ textDiv.className = 'adstage-text-content';
54
+ textDiv.style.cssText = TextAdUtils.createTextAdStyles(false);
55
+
56
+ TextAdUtils.setTextAdContent(textDiv, advertisement); // 최대 라인 수 제한
57
+ const maxLines = (slot.config as any)?.maxLines;
58
+ if (maxLines && typeof maxLines === 'number') {
59
+ textDiv.style.display = '-webkit-box';
60
+ textDiv.style.webkitLineClamp = String(maxLines);
61
+ textDiv.style.webkitBoxOrient = 'vertical';
62
+ textDiv.style.overflow = 'hidden';
63
+ }
64
+
65
+ adElement.appendChild(textDiv);
66
+
67
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
68
+ AdClickHandler.addClickEventForRenderer(
69
+ adElement,
70
+ advertisement,
71
+ slot,
72
+ () => this.createEventTrackingCallback(),
73
+ this.debug,
74
+ 'Text'
75
+ );
76
+
77
+ container.innerHTML = '';
78
+ container.appendChild(adElement);
79
+
80
+ if (this.debug) {
81
+ console.log(`✨ Single text ad rendered: ${advertisement._id}`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 다중 텍스트 광고 렌더링 (전환 효과)
87
+ */
88
+ async renderMultipleAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
89
+ const container = document.getElementById(slot.containerId);
90
+ if (!container) {
91
+ throw new Error(`Container not found: ${slot.containerId}`);
92
+ }
93
+
94
+ const trackEventCallback = this.createEventTrackingCallback();
95
+
96
+ const optimizedSliderOptions = {
97
+ autoSlideInterval: ((slot.config as any)?.slideInterval || 5000) / 1000,
98
+ ...slot.config,
99
+ optimizedHeight: (slot as any).optimizedHeight,
100
+ aspectRatio: (slot as any).aspectRatio
101
+ };
102
+
103
+ const sliderElement = TextTransitionManager.createTextTransitionContainer(
104
+ slot,
105
+ advertisements,
106
+ optimizedSliderOptions,
107
+ trackEventCallback,
108
+ this.debug
109
+ );
110
+
111
+ if (sliderElement) {
112
+ container.innerHTML = '';
113
+ container.appendChild(sliderElement);
114
+
115
+ if (this.debug) {
116
+ console.log(`✨ Text transition created for slot: ${slot.id} with ${advertisements.length} ads`);
117
+ }
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * 비디오 광고 전용 렌더러
3
+ */
4
+
5
+ import { AdSlot, Advertisement, AdType, AdEventType } from '../../../types/advertisement';
6
+ import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
7
+ import { BaseAdRenderer } from './base-ad-renderer';
8
+ import { AdClickHandler } from '../../../utils/ad-click-handler';
9
+
10
+ export class VideoAdRenderer extends BaseAdRenderer {
11
+ constructor(debug: boolean = false, advertisementEventTracker?: AdvertisementEventTracker | null) {
12
+ super(AdType.VIDEO, debug, advertisementEventTracker);
13
+ }
14
+
15
+ /**
16
+ * 비디오 광고 기본 높이 (16:9 비율 고려)
17
+ */
18
+ getDefaultHeight(): string {
19
+ return '360px';
20
+ }
21
+
22
+ /**
23
+ * 단일 비디오 광고 렌더링
24
+ */
25
+ async renderAdElement(slot: AdSlot, advertisement: Advertisement): Promise<void> {
26
+ const container = document.getElementById(slot.containerId);
27
+ if (!container) return;
28
+
29
+ // 비디오 전용 컨테이너 생성
30
+ const videoContainer = document.createElement('div');
31
+ videoContainer.className = 'adstage-video-container';
32
+ videoContainer.style.cssText = `
33
+ width: 100%;
34
+ height: 100%;
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ background: #000;
39
+ border-radius: 8px;
40
+ overflow: hidden;
41
+ `;
42
+
43
+ // 비디오 요소 렌더링
44
+ this.renderVideoElementDirect(videoContainer, advertisement, slot);
45
+
46
+ container.innerHTML = '';
47
+ container.appendChild(videoContainer);
48
+
49
+ if (this.debug) {
50
+ console.log(`🎬 Single video ad rendered: ${advertisement._id}`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 다중 비디오 광고 렌더링 - 비디오는 단일 렌더링만 지원
56
+ */
57
+ async renderMultipleAds(slot: AdSlot, advertisements: Advertisement[]): Promise<void> {
58
+ const container = document.getElementById(slot.containerId);
59
+ if (!container) {
60
+ throw new Error(`Container not found: ${slot.containerId}`);
61
+ }
62
+
63
+ if (advertisements.length > 0) {
64
+ const videoAd = advertisements[0]; // 첫 번째 비디오만 사용
65
+
66
+ // 비디오 전용 컨테이너 생성
67
+ const videoContainer = document.createElement('div');
68
+ videoContainer.className = 'adstage-video-container';
69
+ videoContainer.style.cssText = `
70
+ width: 100%;
71
+ height: 100%;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ background: #000;
76
+ border-radius: 8px;
77
+ overflow: hidden;
78
+ `;
79
+
80
+ // 비디오 요소 렌더링
81
+ this.renderVideoElementDirect(videoContainer, videoAd, slot);
82
+
83
+ // 컨테이너에 추가
84
+ container.innerHTML = '';
85
+ container.appendChild(videoContainer);
86
+
87
+ // VIEWABLE 이벤트 추적
88
+ const trackEventCallback = this.createEventTrackingCallback();
89
+ setTimeout(() => {
90
+ trackEventCallback(videoAd._id, slot.id, AdEventType.VIEWABLE);
91
+ }, 100);
92
+
93
+ if (this.debug) {
94
+ console.log(`🎬 Single video rendered for VIDEO slot: ${slot.id} (${videoAd._id})`);
95
+ }
96
+ } else {
97
+ if (this.debug) {
98
+ console.warn(`⚠️ No video advertisements available for slot: ${slot.id}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 비디오 요소 직접 렌더링
105
+ */
106
+ private renderVideoElementDirect(container: HTMLElement, advertisement: Advertisement, slot: AdSlot): void {
107
+ // 비디오 컨테이너를 relative로 설정
108
+ container.style.position = 'relative';
109
+
110
+ const video = document.createElement('video');
111
+
112
+ // 비디오 기본 설정
113
+ video.style.width = '100%';
114
+ video.style.height = '100%';
115
+ video.style.objectFit = 'contain';
116
+ video.preload = 'metadata';
117
+
118
+ // 슬롯 설정에서 비디오 옵션들 확인 (기본값 적용)
119
+ const config = slot.config as any;
120
+
121
+ // 디버깅: config 내용 확인
122
+ if (this.debug) {
123
+ console.log('🎬 Video config received:', config);
124
+ }
125
+
126
+ // 기본 설정: 모든 컨트롤 숨김 (사용자 요구사항 - config에 상관없이 기본값 적용)
127
+ video.controls = false;
128
+
129
+ // 기본 설정: 자동 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
130
+ video.autoplay = config?.autoplay === false ? false : true;
131
+
132
+ // 기본 설정: 음소거 true (기본값 강제 적용)
133
+ video.muted = true;
134
+
135
+ // 기본 설정: 반복 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
136
+ video.loop = config?.loop === false ? false : true;
137
+
138
+ // 디버깅: 최종 비디오 설정 확인
139
+ if (this.debug) {
140
+ console.log('🎬 Final video settings:', {
141
+ autoplay: video.autoplay,
142
+ muted: video.muted,
143
+ loop: video.loop,
144
+ controls: video.controls
145
+ });
146
+ }
147
+
148
+ // playsinline 설정 (모바일에서 전체화면 방지)
149
+ if (config?.playsinline !== false) {
150
+ video.setAttribute('playsinline', '');
151
+ }
152
+
153
+ // 사용자가 명시적으로 controls=true를 설정한 경우에만 오버라이드 (첫 번째 비디오는 기본값 유지)
154
+ if (config?.controls === true) {
155
+ video.controls = true;
156
+ if (this.debug) {
157
+ console.log('🎬 User explicitly enabled controls, overriding default');
158
+ }
159
+ }
160
+
161
+ // 특별한 컨트롤 설정 처리
162
+ if (config?.hideControls) {
163
+ // 완전히 모든 컨트롤 숨김 (pointer-events까지 차단)
164
+ video.controls = false;
165
+ video.style.cssText += `
166
+ pointer-events: none;
167
+ `;
168
+ } else if (config?.customControls) {
169
+ // controls가 true일 때만 특정 컨트롤 숨기기 (CSS로 처리)
170
+ if (video.controls) {
171
+ const customControlsStyle = document.createElement('style');
172
+ customControlsStyle.textContent = `
173
+ video::-webkit-media-controls-play-button {
174
+ display: ${config.customControls.hidePlayButton ? 'none' : 'block'} !important;
175
+ }
176
+ video::-webkit-media-controls-timeline {
177
+ display: ${config.customControls.hideProgressBar ? 'none' : 'block'} !important;
178
+ }
179
+ video::-webkit-media-controls-current-time-display {
180
+ display: ${config.customControls.hideCurrentTime ? 'none' : 'block'} !important;
181
+ }
182
+ video::-webkit-media-controls-time-remaining-display {
183
+ display: ${config.customControls.hideRemainingTime ? 'none' : 'block'} !important;
184
+ }
185
+ video::-webkit-media-controls-volume-slider {
186
+ display: ${config.customControls.hideVolumeSlider ? 'none' : 'block'} !important;
187
+ }
188
+ video::-webkit-media-controls-mute-button {
189
+ display: ${config.customControls.hideMuteButton ? 'none' : 'block'} !important;
190
+ }
191
+ video::-webkit-media-controls-fullscreen-button {
192
+ display: ${config.customControls.hideFullscreenButton ? 'none' : 'block'} !important;
193
+ }
194
+ `;
195
+ document.head.appendChild(customControlsStyle);
196
+ }
197
+ } else if (video.controls) {
198
+ // controls=true이지만 특별한 설정이 없을 때, 기본 스타일 적용 (시간만 표시)
199
+ const defaultControlsStyle = document.createElement('style');
200
+ defaultControlsStyle.id = 'adstage-video-default-controls';
201
+ defaultControlsStyle.textContent = `
202
+ .adstage-video-container video::-webkit-media-controls-mute-button {
203
+ display: none !important;
204
+ }
205
+ .adstage-video-container video::-webkit-media-controls-fullscreen-button {
206
+ display: none !important;
207
+ }
208
+ .adstage-video-container video::-webkit-media-controls-toggle-closed-captions-button {
209
+ display: none !important;
210
+ }
211
+ .adstage-video-container video::-webkit-media-controls-volume-slider {
212
+ display: none !important;
213
+ }
214
+ .adstage-video-container video::-webkit-media-controls-overflow-button {
215
+ display: none !important;
216
+ }
217
+ .adstage-video-container video::-webkit-media-controls-picture-in-picture-button {
218
+ display: none !important;
219
+ }
220
+ video::-webkit-media-controls-mute-button {
221
+ display: none !important;
222
+ }
223
+ video::-webkit-media-controls-fullscreen-button {
224
+ display: none !important;
225
+ }
226
+ video::-webkit-media-controls-toggle-closed-captions-button {
227
+ display: none !important;
228
+ }
229
+ video::-webkit-media-controls-volume-slider {
230
+ display: none !important;
231
+ }
232
+ video::-webkit-media-controls-overflow-button {
233
+ display: none !important;
234
+ }
235
+ video::-webkit-media-controls-picture-in-picture-button {
236
+ display: none !important;
237
+ }
238
+ `;
239
+
240
+ // 스타일이 이미 존재하지 않으면 추가
241
+ if (!document.getElementById('adstage-video-default-controls')) {
242
+ document.head.appendChild(defaultControlsStyle);
243
+ }
244
+ }
245
+
246
+ // 기본값이 controls=false이므로 아무것도 표시하지 않음
247
+ if (this.debug) {
248
+ console.log('🎬 Video controls setting:', video.controls ? 'enabled' : 'disabled (default)');
249
+ }
250
+
251
+ // 커스텀 음소거 토글 버튼 생성 (기본적으로 항상 표시)
252
+ const muteButton = document.createElement('button');
253
+ muteButton.className = 'adstage-video-mute-button';
254
+ muteButton.style.cssText = `
255
+ position: absolute;
256
+ top: 12px;
257
+ left: 12px;
258
+ width: 40px;
259
+ height: 40px;
260
+ border: none;
261
+ border-radius: 50%;
262
+ background: rgba(0, 0, 0, 0.7);
263
+ color: white;
264
+ font-size: 16px;
265
+ cursor: pointer;
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ z-index: 10;
270
+ transition: all 0.3s ease;
271
+ backdrop-filter: blur(4px);
272
+ `;
273
+
274
+ // hideControls가 true면 음소거 버튼도 숨김
275
+ if (config?.hideControls) {
276
+ muteButton.style.display = 'none';
277
+ }
278
+
279
+ // 음소거 상태에 따른 아이콘 업데이트
280
+ const updateMuteButtonIcon = () => {
281
+ const mutedIcon = `
282
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
283
+ <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"/>
284
+ </svg>
285
+ `;
286
+
287
+ const unmutedIcon = `
288
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
289
+ <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"/>
290
+ </svg>
291
+ `;
292
+
293
+ muteButton.innerHTML = video.muted ? mutedIcon : unmutedIcon;
294
+ muteButton.title = video.muted ? 'Click to unmute' : 'Click to mute';
295
+ };
296
+
297
+ // 초기 아이콘 설정
298
+ updateMuteButtonIcon();
299
+
300
+ // 음소거 버튼 클릭 이벤트
301
+ muteButton.addEventListener('click', (e) => {
302
+ e.stopPropagation(); // 비디오 클릭 이벤트 방지
303
+ video.muted = !video.muted;
304
+ updateMuteButtonIcon();
305
+
306
+ if (this.debug) {
307
+ console.log(`🔊 Video mute toggled: ${video.muted ? 'muted' : 'unmuted'}`);
308
+ }
309
+ });
310
+
311
+ // 마우스 호버 효과
312
+ muteButton.addEventListener('mouseenter', () => {
313
+ muteButton.style.background = 'rgba(0, 0, 0, 0.9)';
314
+ muteButton.style.transform = 'scale(1.1)';
315
+ });
316
+
317
+ muteButton.addEventListener('mouseleave', () => {
318
+ muteButton.style.background = 'rgba(0, 0, 0, 0.7)';
319
+ muteButton.style.transform = 'scale(1)';
320
+ });
321
+
322
+ // 비디오 URL 설정
323
+ if (advertisement.videoUrl) {
324
+ video.src = advertisement.videoUrl;
325
+
326
+ // 자동 재생을 위한 다단계 시도
327
+ const attemptAutoplay = () => {
328
+ if (video.autoplay && video.muted && video.paused) {
329
+ video.play().catch(error => {
330
+ if (this.debug) {
331
+ console.warn('🎬 Auto-play was prevented:', error);
332
+ console.warn('🎬 Trying muted autoplay fallback...');
333
+ }
334
+ // 음소거 상태에서 다시 시도
335
+ video.muted = true;
336
+ video.play().catch(fallbackError => {
337
+ if (this.debug) {
338
+ console.error('🎬 Autoplay completely failed:', fallbackError);
339
+ }
340
+ });
341
+ });
342
+ }
343
+ };
344
+
345
+ // 다양한 이벤트에서 자동 재생 시도
346
+ video.addEventListener('loadedmetadata', () => {
347
+ if (this.debug) {
348
+ console.log('🎬 Video metadata loaded, attempting autoplay...');
349
+ }
350
+ attemptAutoplay();
351
+ });
352
+
353
+ video.addEventListener('canplay', () => {
354
+ if (this.debug) {
355
+ console.log('🎬 Video can play, attempting autoplay...');
356
+ }
357
+ attemptAutoplay();
358
+ });
359
+
360
+ video.addEventListener('loadeddata', () => {
361
+ if (this.debug) {
362
+ console.log('🎬 Video data loaded, attempting autoplay...');
363
+ }
364
+ attemptAutoplay();
365
+ });
366
+
367
+ // 비디오 로드 시작 즉시 한 번 시도
368
+ setTimeout(() => {
369
+ if (this.debug) {
370
+ console.log('🎬 Initial autoplay attempt after timeout...');
371
+ }
372
+ attemptAutoplay();
373
+ }, 100);
374
+
375
+ } else if (advertisement.imageUrl) {
376
+ // 비디오 URL이 없으면 이미지를 대체 표시
377
+ const img = document.createElement('img');
378
+ img.src = advertisement.imageUrl;
379
+ img.style.width = '100%';
380
+ img.style.height = '100%';
381
+ img.style.objectFit = 'contain';
382
+ img.alt = advertisement.title || 'Video thumbnail';
383
+ container.appendChild(img);
384
+ return;
385
+ }
386
+
387
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
388
+ AdClickHandler.addClickEventForRenderer(
389
+ video,
390
+ advertisement,
391
+ slot,
392
+ () => this.createEventTrackingCallback(),
393
+ this.debug,
394
+ 'Video'
395
+ );
396
+
397
+ // 비디오 로드 에러 처리
398
+ video.addEventListener('error', (e) => {
399
+ console.error('Video load failed:', e);
400
+
401
+ // 대체 이미지 표시
402
+ if (advertisement.imageUrl) {
403
+ const img = document.createElement('img');
404
+ img.src = advertisement.imageUrl;
405
+ img.style.width = '100%';
406
+ img.style.height = '100%';
407
+ img.style.objectFit = 'contain';
408
+ img.alt = advertisement.title || 'Video thumbnail';
409
+
410
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
411
+ AdClickHandler.addClickEventForRenderer(
412
+ img,
413
+ advertisement,
414
+ slot,
415
+ () => this.createEventTrackingCallback(),
416
+ this.debug,
417
+ 'Video fallback'
418
+ );
419
+
420
+ container.innerHTML = '';
421
+ container.appendChild(img);
422
+ }
423
+ });
424
+
425
+ // 비디오와 음소거 버튼을 컨테이너에 추가
426
+ container.appendChild(video);
427
+ container.appendChild(muteButton);
428
+
429
+ if (this.debug) {
430
+ console.log(`🎬 Video element created for ad: ${advertisement._id} (autoplay: ${video.autoplay}, muted: ${video.muted}, loop: ${video.loop})`);
431
+ }
432
+ }
433
+ }