@adstage/web-sdk 2.6.0 → 2.6.2

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.
@@ -0,0 +1,4776 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.AdStage = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ // 광고 타입 정의
8
+ var AdType;
9
+ (function (AdType) {
10
+ AdType["BANNER"] = "BANNER";
11
+ AdType["POPUP"] = "POPUP";
12
+ AdType["INTERSTITIAL"] = "INTERSTITIAL";
13
+ AdType["NATIVE"] = "NATIVE";
14
+ AdType["VIDEO"] = "VIDEO";
15
+ AdType["TEXT"] = "TEXT";
16
+ })(AdType || (AdType = {}));
17
+ // 플랫폼 정의
18
+ var Platform;
19
+ (function (Platform) {
20
+ Platform["WEB"] = "WEB";
21
+ Platform["MOBILE"] = "MOBILE";
22
+ })(Platform || (Platform = {}));
23
+ // 광고 이벤트 타입
24
+ var AdEventType;
25
+ (function (AdEventType) {
26
+ AdEventType["VIEWABLE"] = "VIEWABLE";
27
+ AdEventType["CLICK"] = "CLICK";
28
+ })(AdEventType || (AdEventType = {}));
29
+ // 디바이스 타입
30
+ var DeviceType;
31
+ (function (DeviceType) {
32
+ DeviceType["DESKTOP"] = "DESKTOP";
33
+ DeviceType["MOBILE"] = "MOBILE";
34
+ DeviceType["TABLET"] = "TABLET";
35
+ })(DeviceType || (DeviceType = {}));
36
+
37
+ /**
38
+ * SSR 안전한 DOM API 래퍼 클래스
39
+ * 서버사이드 렌더링 환경에서 DOM API 접근 시 오류를 방지합니다.
40
+ */
41
+ class DOMUtils {
42
+ /**
43
+ * 브라우저 환경 여부 체크
44
+ */
45
+ static isBrowser() {
46
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
47
+ }
48
+ /**
49
+ * SSR 환경 여부 체크
50
+ */
51
+ static isSSR() {
52
+ return !this.isBrowser();
53
+ }
54
+ /**
55
+ * DOM 사용 가능 여부 체크
56
+ */
57
+ static canUseDOM() {
58
+ return this.isBrowser() && document.readyState !== undefined;
59
+ }
60
+ /**
61
+ * 안전한 getElementById
62
+ */
63
+ static safeGetElementById(id) {
64
+ if (!this.canUseDOM())
65
+ return null;
66
+ return document.getElementById(id);
67
+ }
68
+ /**
69
+ * 안전한 querySelector
70
+ */
71
+ static safeQuerySelector(selector) {
72
+ if (!this.canUseDOM())
73
+ return null;
74
+ return document.querySelector(selector);
75
+ }
76
+ /**
77
+ * 안전한 querySelectorAll
78
+ */
79
+ static safeQuerySelectorAll(selector) {
80
+ if (!this.canUseDOM())
81
+ return [];
82
+ return Array.from(document.querySelectorAll(selector));
83
+ }
84
+ /**
85
+ * 안전한 createElement
86
+ */
87
+ static safeCreateElement(tagName) {
88
+ if (!this.canUseDOM())
89
+ return null;
90
+ return document.createElement(tagName);
91
+ }
92
+ /**
93
+ * 안전한 addEventListener
94
+ */
95
+ static safeAddEventListener(element, event, handler, options) {
96
+ if (!this.canUseDOM() || !element)
97
+ return;
98
+ element.addEventListener(event, handler, options);
99
+ }
100
+ /**
101
+ * 안전한 removeEventListener
102
+ */
103
+ static safeRemoveEventListener(element, event, handler, options) {
104
+ if (!this.canUseDOM() || !element)
105
+ return;
106
+ element.removeEventListener(event, handler, options);
107
+ }
108
+ /**
109
+ * 안전한 window 속성 접근
110
+ */
111
+ static getWindowProperty(property, defaultValue) {
112
+ if (!this.isBrowser())
113
+ return defaultValue;
114
+ return window[property] ?? defaultValue;
115
+ }
116
+ /**
117
+ * 안전한 document 속성 접근
118
+ */
119
+ static getDocumentProperty(property, defaultValue) {
120
+ if (!this.canUseDOM())
121
+ return defaultValue;
122
+ return document[property] ?? defaultValue;
123
+ }
124
+ /**
125
+ * 안전한 window.open
126
+ */
127
+ static safeWindowOpen(url, target, features) {
128
+ if (!this.isBrowser())
129
+ return null;
130
+ return window.open(url, target, features);
131
+ }
132
+ /**
133
+ * 안전한 getComputedStyle
134
+ */
135
+ static safeGetComputedStyle(element) {
136
+ if (!this.isBrowser() || !element)
137
+ return null;
138
+ return window.getComputedStyle(element);
139
+ }
140
+ /**
141
+ * DOM Ready 상태 체크
142
+ */
143
+ static isDOMReady() {
144
+ if (!this.canUseDOM())
145
+ return false;
146
+ return document.readyState !== 'loading';
147
+ }
148
+ /**
149
+ * DOM Ready 대기 (SSR 안전)
150
+ */
151
+ static waitForDOM() {
152
+ return new Promise((resolve) => {
153
+ if (!this.canUseDOM()) {
154
+ resolve(); // SSR 환경에서는 즉시 resolve
155
+ return;
156
+ }
157
+ if (this.isDOMReady()) {
158
+ resolve();
159
+ }
160
+ else {
161
+ this.safeAddEventListener(document, 'DOMContentLoaded', () => resolve());
162
+ }
163
+ });
164
+ }
165
+ /**
166
+ * 안전한 스타일 적용
167
+ */
168
+ static safeApplyStyles(element, styles) {
169
+ if (!this.canUseDOM() || !element)
170
+ return;
171
+ Object.entries(styles).forEach(([property, value]) => {
172
+ element.style.setProperty(property, value);
173
+ });
174
+ }
175
+ /**
176
+ * 안전한 클래스 추가
177
+ */
178
+ static safeAddClass(element, className) {
179
+ if (!this.canUseDOM() || !element)
180
+ return;
181
+ element.classList.add(className);
182
+ }
183
+ /**
184
+ * 안전한 클래스 제거
185
+ */
186
+ static safeRemoveClass(element, className) {
187
+ if (!this.canUseDOM() || !element)
188
+ return;
189
+ element.classList.remove(className);
190
+ }
191
+ /**
192
+ * 안전한 텍스트 콘텐츠 설정
193
+ */
194
+ static safeSetTextContent(element, text) {
195
+ if (!this.canUseDOM() || !element)
196
+ return;
197
+ element.textContent = text;
198
+ }
199
+ /**
200
+ * 안전한 HTML 콘텐츠 설정
201
+ */
202
+ static safeSetInnerHTML(element, html) {
203
+ if (!this.canUseDOM() || !element)
204
+ return;
205
+ element.innerHTML = html;
206
+ }
207
+ /**
208
+ * 안전한 자식 요소 추가
209
+ */
210
+ static safeAppendChild(parent, child) {
211
+ if (!this.canUseDOM() || !parent || !child)
212
+ return;
213
+ parent.appendChild(child);
214
+ }
215
+ /**
216
+ * 안전한 자식 요소 제거
217
+ */
218
+ static safeRemoveChild(parent, child) {
219
+ if (!this.canUseDOM() || !parent || !child)
220
+ return;
221
+ parent.removeChild(child);
222
+ }
223
+ /**
224
+ * 현재 페이지 정보 가져오기 (SSR 안전)
225
+ */
226
+ static getPageInfo() {
227
+ return {
228
+ url: this.getWindowProperty('location', { href: '' }).href,
229
+ title: this.getDocumentProperty('title', ''),
230
+ referrer: this.getDocumentProperty('referrer', ''),
231
+ };
232
+ }
233
+ /**
234
+ * 뷰포트 정보 가져오기 (SSR 안전)
235
+ */
236
+ static getViewportInfo() {
237
+ return {
238
+ width: this.getWindowProperty('innerWidth', 0),
239
+ height: this.getWindowProperty('innerHeight', 0),
240
+ pixelRatio: this.getWindowProperty('devicePixelRatio', 1),
241
+ };
242
+ }
243
+ /**
244
+ * 스크롤 정보 가져오기 (SSR 안전)
245
+ */
246
+ static getScrollInfo() {
247
+ const scrollTop = this.canUseDOM()
248
+ ? (window.pageYOffset || document.documentElement.scrollTop)
249
+ : 0;
250
+ return {
251
+ scrollTop,
252
+ scrollLeft: this.getWindowProperty('pageXOffset', 0),
253
+ };
254
+ }
255
+ /**
256
+ * DOM 요소가 나타날 때까지 기다리기 (사용자 친화적 API)
257
+ */
258
+ static async waitForElement(id, options = {}) {
259
+ const { timeout = 3000, retryInterval = 100, debug = false } = options;
260
+ if (!this.canUseDOM()) {
261
+ throw new Error('DOM을 사용할 수 없는 환경입니다.');
262
+ }
263
+ // 즉시 찾을 수 있으면 바로 반환
264
+ const immediateElement = document.getElementById(id);
265
+ if (immediateElement) {
266
+ if (debug) {
267
+ console.log(`✅ 컨테이너 즉시 발견: ${id}`);
268
+ }
269
+ return immediateElement;
270
+ }
271
+ if (debug) {
272
+ console.log(`⏳ 컨테이너 대기 시작: ${id} (최대 ${timeout}ms)`);
273
+ }
274
+ return new Promise((resolve, reject) => {
275
+ let attempts = 0;
276
+ const maxAttempts = Math.ceil(timeout / retryInterval);
277
+ const checkElement = () => {
278
+ attempts++;
279
+ const element = document.getElementById(id);
280
+ if (element) {
281
+ if (debug) {
282
+ console.log(`✅ 컨테이너 발견: ${id} (${attempts}번째 시도, ${attempts * retryInterval}ms 경과)`);
283
+ }
284
+ resolve(element);
285
+ return;
286
+ }
287
+ if (attempts >= maxAttempts) {
288
+ const errorMessage = `❌ 컨테이너를 찾을 수 없습니다: "${id}"
289
+
290
+ 다음을 확인해보세요:
291
+ 1. HTML에 id="${id}" 요소가 있는지 확인
292
+ 2. React 등에서 컴포넌트가 렌더링된 후 SDK 호출
293
+ 3. 철자가 정확한지 확인
294
+ 4. 중복된 ID가 없는지 확인
295
+
296
+ 대기 시간: ${timeout}ms (${attempts}번 시도)`;
297
+ if (debug) {
298
+ console.error(errorMessage);
299
+ }
300
+ reject(new Error(errorMessage));
301
+ return;
302
+ }
303
+ if (debug && attempts % 10 === 0) {
304
+ console.log(`⏳ 컨테이너 대기 중: ${id} (${attempts}/${maxAttempts})`);
305
+ }
306
+ // Exponential backoff: 처음엔 빠르게, 나중엔 느리게
307
+ const nextInterval = Math.min(retryInterval * Math.pow(1.2, attempts), 500);
308
+ setTimeout(checkElement, nextInterval);
309
+ };
310
+ // 첫 번째 체크는 즉시 실행
311
+ setTimeout(checkElement, retryInterval);
312
+ });
313
+ }
314
+ /**
315
+ * 여러 DOM 요소를 동시에 기다리기
316
+ */
317
+ static async waitForElements(ids, options = {}) {
318
+ const promises = ids.map(id => this.waitForElement(id, options));
319
+ return Promise.all(promises);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * AdStage SDK - 버전 정보 유틸리티
325
+ */
326
+ // package.json에서 버전 정보 가져오기 (빌드 시 자동으로 교체됨)
327
+ const SDK_VERSION$1 = '"2.6.2"';
328
+ /**
329
+ * SDK 버전 정보 반환
330
+ */
331
+ function getSDKVersion() {
332
+ return SDK_VERSION$1;
333
+ }
334
+
335
+ /**
336
+ * AdStage SDK - 설정 유틸리티
337
+ * 전역 설정에 대한 접근 기능 제공
338
+ */
339
+ class ConfigUtils {
340
+ /**
341
+ * AdStage 전역 설정 반환
342
+ */
343
+ static getConfig() {
344
+ // AdStage 클래스 동적 임포트로 순환 참조 방지
345
+ try {
346
+ const { AdStage } = require('../core/adstage');
347
+ return AdStage.getConfig();
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ }
353
+ /**
354
+ * 고객 앱 버전 반환
355
+ */
356
+ static getAppVersion() {
357
+ const config = ConfigUtils.getConfig();
358
+ return config?.appVersion || '1.0.0';
359
+ }
360
+ /**
361
+ * 디버그 모드 여부 확인
362
+ */
363
+ static isDebugMode() {
364
+ const config = ConfigUtils.getConfig();
365
+ return config?.debug || false;
366
+ }
367
+ /**
368
+ * API 키 반환
369
+ */
370
+ static getApiKey() {
371
+ const config = ConfigUtils.getConfig();
372
+ return config?.apiKey;
373
+ }
374
+ /**
375
+ * 타임아웃 값 반환
376
+ */
377
+ static getTimeout() {
378
+ const config = ConfigUtils.getConfig();
379
+ return config?.timeout || 30000;
380
+ }
381
+ /**
382
+ * 플레이스홀더 모드 반환
383
+ */
384
+ static getPlaceholderMode() {
385
+ const config = ConfigUtils.getConfig();
386
+ return config?.placeholderMode || 'subtle';
387
+ }
388
+ /**
389
+ * 활성화된 모듈 목록 반환
390
+ */
391
+ static getEnabledModules() {
392
+ const config = ConfigUtils.getConfig();
393
+ return config?.modules || ['ads', 'events', 'config'];
394
+ }
395
+ }
396
+
397
+ /**
398
+ * 디바이스 정보 수집 클래스
399
+ * - 브라우저 환경 정보 수집
400
+ * - 디바이스 ID 생성 및 관리
401
+ * - 세션 ID 생성 및 관리
402
+ */
403
+ class DeviceInfoCollector {
404
+ /**
405
+ * 디바이스 ID 생성 및 반환 (SSR 안전)
406
+ */
407
+ static generateDeviceId() {
408
+ if (!DOMUtils.isBrowser())
409
+ return 'ssr_device_' + Date.now();
410
+ const stored = localStorage.getItem('adstage_device_id');
411
+ if (stored)
412
+ return stored;
413
+ const deviceId = 'device_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
414
+ localStorage.setItem('adstage_device_id', deviceId);
415
+ return deviceId;
416
+ }
417
+ /**
418
+ * 세션 ID 생성 및 반환 (SSR 안전)
419
+ */
420
+ static generateSessionId() {
421
+ if (!DOMUtils.isBrowser())
422
+ return 'ssr_session_' + Date.now();
423
+ const stored = sessionStorage.getItem('adstage_session_id');
424
+ if (stored)
425
+ return stored;
426
+ const sessionId = 'session_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
427
+ sessionStorage.setItem('adstage_session_id', sessionId);
428
+ return sessionId;
429
+ }
430
+ /**
431
+ * 모바일 디바이스 여부 확인 (SSR 안전)
432
+ */
433
+ static isMobile() {
434
+ if (!DOMUtils.isBrowser())
435
+ return false;
436
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
437
+ }
438
+ /**
439
+ * 플랫폼 타입 반환 (서버 enum에 맞춤, SSR 안전)
440
+ */
441
+ static getPlatform() {
442
+ if (!DOMUtils.isBrowser())
443
+ return 'web';
444
+ const userAgent = navigator.userAgent.toLowerCase();
445
+ if (/iphone|ipad|ipod/.test(userAgent)) {
446
+ return 'ios';
447
+ }
448
+ if (/android/.test(userAgent)) {
449
+ return 'android';
450
+ }
451
+ if (DeviceInfoCollector.isMobile()) {
452
+ return 'web'; // 모바일 웹
453
+ }
454
+ return 'desktop'; // 데스크톱 웹
455
+ }
456
+ /**
457
+ * 완전한 디바이스 정보 수집
458
+ */
459
+ static collectDeviceInfo() {
460
+ const viewportInfo = DOMUtils.getViewportInfo();
461
+ return {
462
+ deviceId: DeviceInfoCollector.generateDeviceId(),
463
+ sessionId: DeviceInfoCollector.generateSessionId(),
464
+ osVersion: DOMUtils.isBrowser() ? navigator.platform : 'SSR',
465
+ deviceModel: DOMUtils.isBrowser() ? navigator.platform : 'SSR',
466
+ appVersion: ConfigUtils.getAppVersion(), // AdStage.init()에서 설정 또는 기본값 '1.0.0'
467
+ sdkVersion: getSDKVersion(), // package.json에서 동적 로드
468
+ language: DOMUtils.isBrowser() ? (navigator.language || 'ko') : 'ko',
469
+ country: 'KR', // 기본값
470
+ ipAddress: '', // 서버에서 자동으로 설정됨
471
+ userAgent: DOMUtils.isBrowser() ? navigator.userAgent : 'SSR',
472
+ timezone: DOMUtils.isBrowser() ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'UTC',
473
+ viewportWidth: viewportInfo.width,
474
+ viewportHeight: viewportInfo.height,
475
+ screenWidth: DOMUtils.isBrowser() ? screen.width : 0,
476
+ screenHeight: DOMUtils.isBrowser() ? screen.height : 0,
477
+ colorDepth: DOMUtils.isBrowser() ? screen.colorDepth : 24,
478
+ pixelRatio: viewportInfo.pixelRatio,
479
+ connectionType: DOMUtils.isBrowser() ? (navigator.connection?.effectiveType || 'unknown') : 'unknown',
480
+ platform: DeviceInfoCollector.getPlatform(),
481
+ };
482
+ }
483
+ /**
484
+ * 슬롯 위치 정보 가져오기 (SSR 안전)
485
+ */
486
+ static getSlotPosition(containerId) {
487
+ const element = DOMUtils.safeGetElementById(containerId);
488
+ if (!element)
489
+ return 'unknown';
490
+ const rect = element.getBoundingClientRect();
491
+ const scrollInfo = DOMUtils.getScrollInfo();
492
+ return `x:${Math.round(rect.left)},y:${Math.round(rect.top + scrollInfo.scrollTop)}`;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * AdStage SDK - API 헤더 유틸리티
498
+ * 공통 헤더 생성 로직
499
+ */
500
+ class ApiHeaders {
501
+ /**
502
+ * 표준 API 헤더 생성
503
+ */
504
+ static create(apiKey, options) {
505
+ if (!apiKey) {
506
+ throw new Error('API key is required');
507
+ }
508
+ const headers = {
509
+ 'x-api-key': apiKey,
510
+ 'Content-Type': options?.contentType || 'application/json'
511
+ };
512
+ // User-Agent는 이벤트 추적에서 실제로 사용됨
513
+ if (typeof navigator !== 'undefined') {
514
+ headers['User-Agent'] = options?.userAgent || navigator.userAgent;
515
+ }
516
+ // X-Current-URL은 현재 서버에서 사용하지 않으므로 제거
517
+ // 필요시 이벤트 데이터 body에 포함
518
+ return headers;
519
+ }
520
+ /**
521
+ * 이벤트 추적용 헤더 생성
522
+ * User-Agent는 서버에서 실제로 사용됨
523
+ */
524
+ static createForEvents(apiKey, eventData) {
525
+ const baseHeaders = ApiHeaders.create(apiKey);
526
+ // User-Agent 오버라이드 (서버에서 실제 사용)
527
+ if (eventData?.userAgent) {
528
+ baseHeaders['User-Agent'] = eventData.userAgent;
529
+ }
530
+ // 다른 정보들은 HTTP 헤더가 아닌 이벤트 데이터 body에 포함하는 것이 적절
531
+ // (currentUrl, referrer 등은 POST body로 전송)
532
+ return baseHeaders;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * 광고 이벤트 추적 관리 클래스
538
+ * - 광고 전용 이벤트 추적 및 전송
539
+ * - Viewable 이벤트 중복 방지 통합
540
+ * - 광고 서버 API 통신
541
+ */
542
+ class AdvertisementEventTracker {
543
+ constructor(baseUrl, apiKey, debug, slots) {
544
+ this.baseUrl = baseUrl;
545
+ this.apiKey = apiKey;
546
+ this.debug = debug;
547
+ this.slots = slots;
548
+ }
549
+ /**
550
+ * 광고 이벤트 추적 - 단순화된 viewable 처리
551
+ */
552
+ async trackAdvertisementEvent(adId, slotId, eventType) {
553
+ try {
554
+ if (this.debug) {
555
+ console.log(`🚀 AdvertisementEventTracker: Processing ${eventType} event for ad ${adId} in slot ${slotId}`);
556
+ }
557
+ // 현재 슬롯 정보 가져오기
558
+ const slot = this.slots.get(slotId);
559
+ // 디바이스 정보 수집
560
+ const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
561
+ // 광고 이벤트 데이터 구성 (단순화됨)
562
+ const eventData = {
563
+ // 필수 필드들 (DTO 검증용)
564
+ adType: slot?.adType || 'BANNER',
565
+ platform: deviceInfo.platform,
566
+ deviceId: deviceInfo.deviceId,
567
+ // 디바이스 정보는 deviceInfo 객체로 래핑
568
+ deviceInfo: deviceInfo,
569
+ // 페이지 및 슬롯 정보
570
+ pageUrl: DOMUtils.getPageInfo().url,
571
+ pageTitle: DOMUtils.getPageInfo().title,
572
+ referrer: DOMUtils.getPageInfo().referrer,
573
+ slotId,
574
+ slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
575
+ slotWidth: AdvertisementEventTracker.parseNumericValue(slot?.width),
576
+ slotHeight: AdvertisementEventTracker.parseNumericValue(slot?.height),
577
+ sessionId: deviceInfo.sessionId,
578
+ // 성능 메트릭
579
+ pageLoadTime: performance.now(),
580
+ // 추가 메타데이터
581
+ metadata: {
582
+ eventType,
583
+ sdkVersion: getSDKVersion(),
584
+ timestamp: Date.now(),
585
+ },
586
+ // VIEWABLE 이벤트의 경우 단순한 플래그만 설정
587
+ ...(eventType === AdEventType.VIEWABLE && {
588
+ isViewable: true,
589
+ iabCompliant: true, // 50% 노출 기준으로 단순 판정
590
+ }),
591
+ };
592
+ const url = `${this.baseUrl}/advertisements/events/${adId}/${eventType}`;
593
+ const headers = ApiHeaders.createForEvents(this.apiKey, eventData);
594
+ if (this.debug) {
595
+ console.log(`🚀 Sending advertisement event: ${eventType} for ad ${adId}`, {
596
+ url,
597
+ headers,
598
+ eventData
599
+ });
600
+ console.log(`🌐 Full API call details:`, {
601
+ method: 'POST',
602
+ url,
603
+ hasApiKey: !!this.apiKey,
604
+ bodySize: JSON.stringify(eventData).length
605
+ });
606
+ }
607
+ const response = await fetch(url, {
608
+ method: 'POST',
609
+ headers,
610
+ body: JSON.stringify(eventData),
611
+ });
612
+ if (this.debug) {
613
+ console.log(`📡 API Response Status: ${response.status} ${response.statusText}`, {
614
+ url,
615
+ ok: response.ok
616
+ });
617
+ }
618
+ if (!response.ok) {
619
+ const errorText = await response.text();
620
+ throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`);
621
+ }
622
+ if (this.debug) {
623
+ console.log(`✅ Successfully tracked advertisement event: ${eventType} for ad ${adId}`);
624
+ }
625
+ }
626
+ catch (error) {
627
+ console.error('❌ Failed to track advertisement event:', error);
628
+ console.error('🔍 Debug info:', {
629
+ baseUrl: this.baseUrl,
630
+ apiKey: this.apiKey ? `${this.apiKey.substring(0, 8)}...` : 'NOT_SET',
631
+ url: `${this.baseUrl}/advertisements/events/${adId}/${eventType}`,
632
+ eventType,
633
+ adId,
634
+ slotId
635
+ });
636
+ }
637
+ }
638
+ /**
639
+ * 크기 값을 숫자로 변환 (서버 API용)
640
+ */
641
+ static parseNumericValue(value) {
642
+ if (typeof value === 'number') {
643
+ return value;
644
+ }
645
+ if (typeof value === 'string') {
646
+ // px 단위 제거하고 숫자만 추출
647
+ const numericValue = parseFloat(value.replace(/px$/, ''));
648
+ return isNaN(numericValue) ? 0 : numericValue;
649
+ }
650
+ return 0; // 기본값
651
+ }
652
+ }
653
+
654
+ /**
655
+ * 단순한 광고 노출 추적 (50% 노출시 즉시 VIEWABLE 이벤트)
656
+ */
657
+ class SimpleViewabilityTracker {
658
+ constructor(element, onViewable) {
659
+ this.observer = null;
660
+ this.isViewableTriggered = false;
661
+ this.element = element;
662
+ this.onViewableCallback = onViewable;
663
+ this.initIntersectionObserver();
664
+ }
665
+ initIntersectionObserver() {
666
+ // IntersectionObserver 지원 확인
667
+ if (!('IntersectionObserver' in window)) {
668
+ console.warn('IntersectionObserver not supported, viewability tracking disabled');
669
+ return;
670
+ }
671
+ this.observer = new IntersectionObserver((entries) => this.handleIntersection(entries), {
672
+ threshold: 0.5, // 50% 노출
673
+ rootMargin: '0px'
674
+ });
675
+ this.observer.observe(this.element);
676
+ }
677
+ handleIntersection(entries) {
678
+ entries.forEach(entry => {
679
+ // 50% 이상 노출되고 문서가 가시상태이며 아직 트리거되지 않은 경우
680
+ if (entry.intersectionRatio >= 0.5 &&
681
+ this.isDocumentVisible() &&
682
+ !this.isViewableTriggered) {
683
+ this.isViewableTriggered = true;
684
+ if (this.onViewableCallback) {
685
+ this.onViewableCallback();
686
+ }
687
+ }
688
+ });
689
+ }
690
+ isDocumentVisible() {
691
+ return !document.hidden && document.visibilityState === 'visible';
692
+ }
693
+ destroy() {
694
+ if (this.observer) {
695
+ this.observer.disconnect();
696
+ this.observer = null;
697
+ }
698
+ }
699
+ }
700
+
701
+ /**
702
+ * AdStage SDK 엔드포인트 상수 관리
703
+ * 모든 API URL을 중앙에서 관리
704
+ */
705
+ /**
706
+ * 환경별 API 엔드포인트 (읽기 전용)
707
+ */
708
+ const API_ENDPOINTS = {
709
+ /** 프로덕션 환경 */
710
+ production: 'https://api.adstage.io',
711
+ /** 베타 환경 (기본값) */
712
+ beta: 'https://beta-api.adstage.app'
713
+ };
714
+ /**
715
+ * API 경로 상수
716
+ */
717
+ const API_PATHS = {
718
+ /** 광고 관련 */
719
+ advertisements: {
720
+ list: '/advertisements/list',
721
+ detail: '/advertisements',
722
+ events: '/advertisements/events'
723
+ },
724
+ /** 이벤트 관련 */
725
+ events: {
726
+ track: '/events/track',
727
+ }
728
+ };
729
+ /**
730
+ * 완전한 API URL 생성 헬퍼
731
+ */
732
+ class EndpointBuilder {
733
+ constructor(baseUrl) {
734
+ /**
735
+ * 광고 엔드포인트
736
+ */
737
+ this.advertisements = {
738
+ list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
739
+ detail: (adId) => `${this.baseUrl}${API_PATHS.advertisements.detail}/${adId}`,
740
+ events: (adId, eventType) => `${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
741
+ };
742
+ /**
743
+ * 이벤트 엔드포인트
744
+ */
745
+ this.events = {
746
+ track: () => `${this.baseUrl}${API_PATHS.events.track}`,
747
+ };
748
+ // 기본값은 베타 환경 사용
749
+ this.baseUrl = baseUrl || API_ENDPOINTS.production;
750
+ }
751
+ /**
752
+ * 기본 URL 변경
753
+ */
754
+ setBaseUrl(url) {
755
+ this.baseUrl = url;
756
+ }
757
+ /**
758
+ * 기본 URL 반환
759
+ */
760
+ getBaseUrl() {
761
+ return this.baseUrl;
762
+ }
763
+ /**
764
+ * 커스텀 경로 생성
765
+ */
766
+ custom(path) {
767
+ return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
768
+ }
769
+ }
770
+ /**
771
+ * 전역 엔드포인트 빌더 인스턴스 (기본: 베타 환경)
772
+ */
773
+ const endpoints = new EndpointBuilder();
774
+
775
+ /**
776
+ * 슬라이더 이벤트 추적 공통 유틸리티
777
+ * - 모든 슬라이더 타입에서 일관된 VIEWABLE 이벤트 추적
778
+ * - 중복 방지는 상위 레벨에서 처리
779
+ */
780
+ class SliderEventTracker {
781
+ /**
782
+ * 슬라이드 변경 시 VIEWABLE 이벤트 추적
783
+ * @param advertisement 현재 슬라이드의 광고
784
+ * @param slot 광고 슬롯
785
+ * @param slideIndex 현재 슬라이드 인덱스
786
+ * @param trackEventCallback 이벤트 추적 콜백
787
+ * @param debug 디버그 모드
788
+ */
789
+ static trackSlideViewable(advertisement, slot, slideIndex, trackEventCallback, debug = false) {
790
+ if (debug) {
791
+ console.log(`🎯 Triggering VIEWABLE event for slide change: ad ${advertisement._id} (index: ${slideIndex}) in slot: ${slot.id}`);
792
+ }
793
+ // 모든 슬라이드에 대해 VIEWABLE 이벤트 추적 (첫 번째 포함)
794
+ trackEventCallback(advertisement._id, slot.id, AdEventType.VIEWABLE);
795
+ }
796
+ /**
797
+ * 초기 슬라이드 로딩 시 VIEWABLE 이벤트 추적
798
+ * @param advertisement 첫 번째 슬라이드의 광고
799
+ * @param slot 광고 슬롯
800
+ * @param trackEventCallback 이벤트 추적 콜백
801
+ * @param debug 디버그 모드
802
+ */
803
+ static trackInitialSlideViewable(advertisement, slot, trackEventCallback, debug = false) {
804
+ if (debug) {
805
+ console.log(`🎯 Triggering initial VIEWABLE event: ad ${advertisement._id} (index: 0) in slot: ${slot.id}`);
806
+ }
807
+ // 첫 번째 슬라이드도 동일하게 추적
808
+ trackEventCallback(advertisement._id, slot.id, AdEventType.VIEWABLE);
809
+ }
810
+ }
811
+
812
+ /**
813
+ * 광고 클릭 이벤트 핸들러 유틸리티
814
+ * 모든 광고 타입에서 일관된 클릭 이벤트 처리를 위한 공통 컴포넌트
815
+ */
816
+ class AdClickHandler {
817
+ /**
818
+ * 광고 요소에 클릭 이벤트를 추가하는 공통 함수
819
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
820
+ * @param advertisement - 광고 데이터
821
+ * @param slot - 광고 슬롯 정보
822
+ * @param trackEventCallback - 이벤트 추적 콜백 함수
823
+ * @param debug - 디버그 모드
824
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
825
+ */
826
+ static addClickEvent(element, advertisement, slot, trackEventCallback, debug = false, adTypeLabel) {
827
+ // linkUrl이 없으면 클릭 이벤트 추가하지 않음
828
+ if (!advertisement.linkUrl) {
829
+ return;
830
+ }
831
+ // 커서 스타일 설정
832
+ element.style.cursor = 'pointer';
833
+ // 클릭 이벤트 리스너 추가
834
+ element.addEventListener('click', (e) => {
835
+ e.preventDefault();
836
+ e.stopPropagation();
837
+ // 이벤트 추적
838
+ if (trackEventCallback) {
839
+ trackEventCallback(advertisement._id, slot.id, AdEventType.CLICK);
840
+ }
841
+ // 링크 이동
842
+ window.open(advertisement.linkUrl, '_blank');
843
+ // 디버그 로그
844
+ if (debug) {
845
+ const typeLabel = adTypeLabel || String(slot.adType).toLowerCase();
846
+ console.log(`🔗 ${typeLabel} ad clicked: ${advertisement._id} -> ${advertisement.linkUrl}`);
847
+ }
848
+ });
849
+ }
850
+ /**
851
+ * 렌더러에서 사용할 수 있는 간편한 클릭 이벤트 추가 함수
852
+ * BaseAdRenderer를 상속받은 클래스에서 사용
853
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
854
+ * @param advertisement - 광고 데이터
855
+ * @param slot - 광고 슬롯 정보
856
+ * @param createEventTrackingCallback - 이벤트 추적 콜백 생성 함수
857
+ * @param debug - 디버그 모드
858
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
859
+ */
860
+ static addClickEventForRenderer(element, advertisement, slot, createEventTrackingCallback, debug = false, adTypeLabel) {
861
+ // linkUrl이 없으면 클릭 이벤트 추가하지 않음
862
+ if (!advertisement.linkUrl) {
863
+ return;
864
+ }
865
+ // 커서 스타일 설정
866
+ element.style.cursor = 'pointer';
867
+ // 클릭 이벤트 리스너 추가
868
+ element.addEventListener('click', (e) => {
869
+ e.preventDefault();
870
+ e.stopPropagation();
871
+ // 이벤트 추적 콜백 생성
872
+ const trackEventCallback = createEventTrackingCallback();
873
+ trackEventCallback(advertisement._id, slot.id, AdEventType.CLICK);
874
+ // 링크 이동
875
+ window.open(advertisement.linkUrl, '_blank');
876
+ // 디버그 로그
877
+ if (debug) {
878
+ const typeLabel = adTypeLabel || String(slot.adType).toLowerCase();
879
+ console.log(`🔗 ${typeLabel} ad clicked: ${advertisement._id} -> ${advertisement.linkUrl}`);
880
+ }
881
+ });
882
+ }
883
+ /**
884
+ * 슬라이더/매니저에서 사용할 수 있는 클릭 이벤트 추가 함수
885
+ * 이미 trackEventCallback이 준비된 상황에서 사용
886
+ * @param element - 클릭 이벤트를 추가할 DOM 요소
887
+ * @param advertisement - 광고 데이터
888
+ * @param slot - 광고 슬롯 정보
889
+ * @param trackEventCallback - 준비된 이벤트 추적 콜백
890
+ * @param debug - 디버그 모드
891
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
892
+ */
893
+ static addClickEventForSlider(element, advertisement, slot, trackEventCallback, debug = false, adTypeLabel) {
894
+ this.addClickEvent(element, advertisement, slot, trackEventCallback, debug, adTypeLabel);
895
+ }
896
+ /**
897
+ * 클릭 가능한 광고인지 확인하는 헬퍼 함수
898
+ * @param advertisement - 광고 데이터
899
+ * @returns linkUrl이 있으면 true, 없으면 false
900
+ */
901
+ static isClickable(advertisement) {
902
+ return Boolean(advertisement.linkUrl);
903
+ }
904
+ /**
905
+ * 여러 요소에 대해 일괄적으로 클릭 이벤트를 추가하는 함수
906
+ * @param elements - 클릭 이벤트를 추가할 DOM 요소들
907
+ * @param advertisements - 광고 데이터 배열 (elements와 같은 순서)
908
+ * @param slot - 광고 슬롯 정보
909
+ * @param trackEventCallback - 이벤트 추적 콜백
910
+ * @param debug - 디버그 모드
911
+ * @param adTypeLabel - 광고 타입 라벨 (로그용, 선택사항)
912
+ */
913
+ static addClickEventsBatch(elements, advertisements, slot, trackEventCallback, debug = false, adTypeLabel) {
914
+ elements.forEach((element, index) => {
915
+ const advertisement = advertisements[index];
916
+ if (advertisement) {
917
+ this.addClickEvent(element, advertisement, slot, trackEventCallback, debug, adTypeLabel);
918
+ }
919
+ });
920
+ }
921
+ }
922
+
923
+ /**
924
+ * 캐러셀 슬라이더 관리 클래스
925
+ * - 배너/비디오 광고용 가로 슬라이드 (횡 스크롤)
926
+ * - 무한 루프 캐러셀 지원
927
+ * - 터치 제스처 및 자동 슬라이드 기능
928
+ * - 도트 인디케이터 포함
929
+ */
930
+ class CarouselSliderManager {
931
+ /**
932
+ * 간단한 광고 요소 생성 (크기 측정용)
933
+ */
934
+ static createSimpleAdElement(slot, advertisement) {
935
+ const adElement = document.createElement('div');
936
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
937
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
938
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
939
+ // 기본 스타일 설정
940
+ adElement.style.display = 'block';
941
+ adElement.style.width = '100%';
942
+ adElement.style.height = 'auto';
943
+ // 광고 타입별 기본 컨테이너 설정
944
+ switch (slot.adType) {
945
+ case AdType.BANNER:
946
+ if (advertisement.imageUrl) {
947
+ const img = document.createElement('img');
948
+ img.src = advertisement.imageUrl;
949
+ img.style.width = '100%';
950
+ img.style.height = 'auto';
951
+ img.style.objectFit = 'cover';
952
+ adElement.appendChild(img);
953
+ }
954
+ else {
955
+ adElement.style.height = '100px';
956
+ adElement.style.backgroundColor = '#f0f0f0';
957
+ adElement.style.border = '1px dashed #ccc';
958
+ adElement.textContent = 'Banner Ad';
959
+ }
960
+ break;
961
+ case AdType.VIDEO:
962
+ if (advertisement.videoUrl) {
963
+ const video = document.createElement('video');
964
+ video.src = advertisement.videoUrl;
965
+ video.style.width = '100%';
966
+ video.style.height = 'auto';
967
+ adElement.appendChild(video);
968
+ }
969
+ else {
970
+ adElement.style.height = '200px';
971
+ adElement.style.backgroundColor = '#000';
972
+ adElement.style.border = '1px solid #666';
973
+ adElement.textContent = 'Video Ad';
974
+ adElement.style.color = 'white';
975
+ }
976
+ break;
977
+ case AdType.TEXT:
978
+ if (advertisement.textContent) {
979
+ const textDiv = document.createElement('div');
980
+ textDiv.textContent = advertisement.textContent || '';
981
+ textDiv.style.padding = '8px';
982
+ textDiv.style.fontSize = '14px';
983
+ adElement.appendChild(textDiv);
984
+ }
985
+ else {
986
+ adElement.style.height = '50px';
987
+ adElement.style.padding = '8px';
988
+ adElement.textContent = 'Text Ad';
989
+ }
990
+ break;
991
+ default:
992
+ adElement.style.height = '100px';
993
+ adElement.style.border = '1px dashed #ccc';
994
+ adElement.style.backgroundColor = '#f9f9f9';
995
+ adElement.textContent = `${slot.adType} Ad`;
996
+ }
997
+ return adElement;
998
+ }
999
+ /**
1000
+ * Create carousel slider container with dot indicators and navigation
1001
+ */
1002
+ static createSliderContainer(slot, advertisements, options, trackEventCallback, debug = false) {
1003
+ const sliderWrapper = document.createElement('div');
1004
+ sliderWrapper.className = 'adstage-slider-wrapper';
1005
+ // 사용자 지정 크기가 있으면 적용, 없으면 콘텐츠 크기에 맞춤
1006
+ const containerStyles = {
1007
+ position: 'relative',
1008
+ overflow: 'hidden',
1009
+ };
1010
+ // 사용자가 크기를 지정한 경우
1011
+ if (slot.width && slot.width !== 0) {
1012
+ let width;
1013
+ if (typeof slot.width === 'string') {
1014
+ // 문자열인 경우 px 단위가 있는지 확인
1015
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
1016
+ }
1017
+ else {
1018
+ // 숫자인 경우 px 단위 추가
1019
+ width = `${slot.width}px`;
1020
+ }
1021
+ containerStyles.width = width;
1022
+ containerStyles.display = 'inline-block'; // 지정된 크기에 맞춤 (좌측 정렬)
1023
+ }
1024
+ else {
1025
+ // 컨텐츠 크기에 맞춤
1026
+ containerStyles.display = 'inline-block';
1027
+ }
1028
+ if (slot.height && slot.height !== 0) {
1029
+ const height = typeof slot.height === 'string' ? slot.height : `${slot.height}px`;
1030
+ containerStyles.height = height;
1031
+ }
1032
+ // 스타일 적용
1033
+ Object.entries(containerStyles).forEach(([key, value]) => {
1034
+ sliderWrapper.style.setProperty(key, value);
1035
+ });
1036
+ // 크기 측정 (width나 height가 설정되지 않은 경우)
1037
+ const needsWidthMeasurement = !slot.width || slot.width === 0;
1038
+ const needsHeightMeasurement = !slot.height || slot.height === 0;
1039
+ if (needsWidthMeasurement || needsHeightMeasurement) {
1040
+ const measureContainer = document.createElement('div');
1041
+ measureContainer.style.cssText = `
1042
+ position: absolute;
1043
+ visibility: hidden;
1044
+ white-space: nowrap;
1045
+ top: -9999px;
1046
+ left: -9999px;
1047
+ `;
1048
+ // width가 설정되어 있으면 측정 컨테이너에도 적용
1049
+ if (!needsWidthMeasurement && slot.width) {
1050
+ let width;
1051
+ if (typeof slot.width === 'string') {
1052
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
1053
+ }
1054
+ else {
1055
+ width = `${slot.width}px`;
1056
+ }
1057
+ measureContainer.style.width = width;
1058
+ measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
1059
+ }
1060
+ document.body.appendChild(measureContainer);
1061
+ let maxWidth = 0;
1062
+ let maxHeight = 0;
1063
+ // 모든 광고의 크기를 측정하여 최대 크기 찾기
1064
+ advertisements.forEach(ad => {
1065
+ const measureAdElement = this.createSimpleAdElement(slot, ad);
1066
+ measureContainer.appendChild(measureAdElement);
1067
+ const rect = measureAdElement.getBoundingClientRect();
1068
+ if (rect.width > maxWidth)
1069
+ maxWidth = rect.width;
1070
+ if (rect.height > maxHeight)
1071
+ maxHeight = rect.height;
1072
+ // 측정 후 요소 제거
1073
+ measureContainer.removeChild(measureAdElement);
1074
+ });
1075
+ // 측정된 최대 크기로 래퍼 크기 설정
1076
+ if (needsWidthMeasurement && maxWidth > 0) {
1077
+ sliderWrapper.style.width = `${maxWidth}px`;
1078
+ containerStyles.width = `${maxWidth}px`;
1079
+ }
1080
+ if (needsHeightMeasurement && maxHeight > 0) {
1081
+ sliderWrapper.style.height = `${maxHeight}px`;
1082
+ containerStyles.height = `${maxHeight}px`;
1083
+ }
1084
+ // 측정 컨테이너 제거
1085
+ document.body.removeChild(measureContainer);
1086
+ }
1087
+ // 무한 루프를 위해 첫 번째 슬라이드를 마지막에 복사
1088
+ const extendedAds = [...advertisements, advertisements[0]];
1089
+ // 슬라이드 컨테이너
1090
+ const slideContainer = document.createElement('div');
1091
+ slideContainer.className = 'adstage-slide-container';
1092
+ // 슬라이드 컨테이너 스타일 - 항상 기본 설정 적용
1093
+ const slideContainerStyles = {
1094
+ display: 'flex',
1095
+ transition: 'transform 0.4s ease-out',
1096
+ width: `${extendedAds.length * 100}%`,
1097
+ };
1098
+ if (slot.height && slot.height !== 0) {
1099
+ slideContainerStyles.height = '100%';
1100
+ }
1101
+ Object.entries(slideContainerStyles).forEach(([key, value]) => {
1102
+ slideContainer.style.setProperty(key, value);
1103
+ });
1104
+ // 각 광고를 슬라이드로 생성 (복사된 첫 번째 포함)
1105
+ extendedAds.forEach((ad, index) => {
1106
+ const slideElement = document.createElement('div');
1107
+ slideElement.className = 'adstage-slide';
1108
+ // 슬라이드 스타일 설정 - 항상 균등 분할
1109
+ const slideStyles = {
1110
+ width: `${100 / extendedAds.length}%`,
1111
+ 'flex-shrink': '0',
1112
+ display: 'flex',
1113
+ 'align-items': 'center',
1114
+ 'justify-content': 'center'
1115
+ };
1116
+ if (slot.height && slot.height !== 0) {
1117
+ slideStyles.height = '100%';
1118
+ }
1119
+ Object.entries(slideStyles).forEach(([key, value]) => {
1120
+ slideElement.style.setProperty(key, value);
1121
+ });
1122
+ // 광고 렌더링
1123
+ const adElement = this.createSimpleAdElement(slot, ad);
1124
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1125
+ AdClickHandler.addClickEventForSlider(adElement, ad, slot, trackEventCallback, debug, String(slot.adType).toLowerCase());
1126
+ slideElement.appendChild(adElement);
1127
+ slideContainer.appendChild(slideElement);
1128
+ });
1129
+ // 텍스트 광고인지 확인 (모든 광고가 텍스트 타입인 경우)
1130
+ const isAllTextAds = advertisements.every(ad => ad.adType === AdType.TEXT);
1131
+ // 무채색 도트 인디케이터 생성 (원본 광고 수만큼) - 텍스트 광고가 아닐 때만
1132
+ const dotContainer = isAllTextAds ? null : this.createMinimalDotIndicator(advertisements.length);
1133
+ // 슬라이더 상태 관리
1134
+ let currentSlide = 0;
1135
+ const totalSlides = advertisements.length;
1136
+ const autoSlideInterval = (options?.autoSlideInterval || 3) * 1000; // 기본 3초
1137
+ // 슬라이드 이동 함수 (무한 루프 지원)
1138
+ const moveToSlide = (index, instant = false) => {
1139
+ currentSlide = index;
1140
+ // 애니메이션 임시 비활성화 (무한 루프용)
1141
+ if (instant) {
1142
+ slideContainer.style.transition = 'none';
1143
+ }
1144
+ else {
1145
+ slideContainer.style.transition = 'transform 0.4s ease-out';
1146
+ }
1147
+ // 항상 퍼센트 기반으로 이동
1148
+ slideContainer.style.transform = `translateX(-${(100 / extendedAds.length) * currentSlide}%)`;
1149
+ // 도트 업데이트 (무채색 스타일) - 실제 광고 인덱스 기준, 텍스트 광고가 아닐 때만
1150
+ const actualIndex = currentSlide === totalSlides ? 0 : currentSlide;
1151
+ if (dotContainer) {
1152
+ const dots = dotContainer.querySelectorAll('.adstage-dot');
1153
+ dots.forEach((dot, i) => {
1154
+ const dotElement = dot;
1155
+ if (i === actualIndex) {
1156
+ dotElement.classList.add('active');
1157
+ dotElement.style.background = '#666666';
1158
+ dotElement.style.borderColor = '#666666';
1159
+ dotElement.style.opacity = '1';
1160
+ }
1161
+ else {
1162
+ dotElement.classList.remove('active');
1163
+ dotElement.style.background = 'transparent';
1164
+ dotElement.style.borderColor = '#cccccc';
1165
+ dotElement.style.opacity = '0.7';
1166
+ }
1167
+ });
1168
+ }
1169
+ // 🎯 공통 슬라이더 이벤트 추적 적용 (모든 슬라이드 포함)
1170
+ SliderEventTracker.trackSlideViewable(advertisements[actualIndex], slot, actualIndex, trackEventCallback, debug // debug 모드
1171
+ );
1172
+ };
1173
+ // 무한 루프 처리 함수
1174
+ const handleInfiniteLoop = () => {
1175
+ if (currentSlide === totalSlides) {
1176
+ // 복사된 첫 번째 슬라이드에 도달하면 즉시 원본 첫 번째로 이동
1177
+ setTimeout(() => {
1178
+ moveToSlide(0, true); // 애니메이션 없이 즉시 이동
1179
+ }, 400); // transition 시간과 맞춤
1180
+ }
1181
+ };
1182
+ // 도트 클릭 이벤트 (텍스트 광고가 아닐 때만)
1183
+ if (dotContainer) {
1184
+ const dots = dotContainer.querySelectorAll('.adstage-dot');
1185
+ dots.forEach((dot, index) => {
1186
+ dot.addEventListener('click', () => moveToSlide(index));
1187
+ });
1188
+ }
1189
+ // 자동 슬라이드 (한 방향으로만 무한 진행)
1190
+ let autoSlideTimer = setInterval(() => {
1191
+ const nextIndex = currentSlide + 1;
1192
+ moveToSlide(nextIndex);
1193
+ handleInfiniteLoop();
1194
+ }, autoSlideInterval);
1195
+ // 마우스 호버 시 자동 슬라이드 일시정지
1196
+ sliderWrapper.addEventListener('mouseenter', () => {
1197
+ clearInterval(autoSlideTimer);
1198
+ });
1199
+ sliderWrapper.addEventListener('mouseleave', () => {
1200
+ autoSlideTimer = setInterval(() => {
1201
+ const nextIndex = currentSlide + 1;
1202
+ moveToSlide(nextIndex);
1203
+ handleInfiniteLoop();
1204
+ }, autoSlideInterval);
1205
+ });
1206
+ // 터치 제스처 지원 수정 (무한 루프 지원)
1207
+ this.addTouchSupport(slideContainer, moveToSlide, () => currentSlide, totalSlides, handleInfiniteLoop);
1208
+ // 요소들 조립 (화살표 제거, 도트는 텍스트 광고가 아닐 때만 추가)
1209
+ sliderWrapper.appendChild(slideContainer);
1210
+ if (dotContainer) {
1211
+ sliderWrapper.appendChild(dotContainer);
1212
+ }
1213
+ // 첫 번째 도트 활성화 (moveToSlide에서 자동으로 VIEWABLE 이벤트 발생)
1214
+ moveToSlide(0);
1215
+ // 사용자가 크기를 지정하지 않은 경우, 첫 번째 슬라이드 크기에 맞춰 래퍼 크기 동적 조정
1216
+ if (!slot.width || slot.width === 0) {
1217
+ // DOM 렌더링 후 크기 측정
1218
+ setTimeout(() => {
1219
+ const firstSlide = slideContainer.children[0];
1220
+ if (firstSlide) {
1221
+ const firstAdElement = firstSlide.children[0];
1222
+ if (firstAdElement) {
1223
+ const rect = firstAdElement.getBoundingClientRect();
1224
+ sliderWrapper.style.width = `${rect.width}px`;
1225
+ if (!slot.height || slot.height === 0) {
1226
+ sliderWrapper.style.height = `${rect.height}px`;
1227
+ }
1228
+ // 크기 조정 후 overflow hidden 재적용
1229
+ sliderWrapper.style.overflow = 'hidden';
1230
+ }
1231
+ }
1232
+ }, 10);
1233
+ }
1234
+ return sliderWrapper;
1235
+ }
1236
+ /**
1237
+ * 무채색 미니멀 도트 인디케이터 생성
1238
+ */
1239
+ static createMinimalDotIndicator(count) {
1240
+ const dotContainer = document.createElement('div');
1241
+ dotContainer.className = 'adstage-dots';
1242
+ dotContainer.style.cssText = `
1243
+ position: absolute;
1244
+ bottom: 15px;
1245
+ left: 50%;
1246
+ transform: translateX(-50%);
1247
+ display: flex;
1248
+ gap: 12px;
1249
+ z-index: 3;
1250
+ padding: 8px 16px;
1251
+ border-radius: 20px;
1252
+ background: rgba(255, 255, 255, 0.1);
1253
+ backdrop-filter: blur(10px);
1254
+ `;
1255
+ for (let i = 0; i < count; i++) {
1256
+ const dot = document.createElement('button');
1257
+ dot.className = 'adstage-dot';
1258
+ dot.style.cssText = `
1259
+ width: 8px;
1260
+ height: 8px;
1261
+ border-radius: 50%;
1262
+ border: 1px solid #cccccc;
1263
+ background: transparent;
1264
+ cursor: pointer;
1265
+ transition: all 0.3s ease;
1266
+ outline: none;
1267
+ opacity: 0.7;
1268
+ padding: 0;
1269
+ margin: 0;
1270
+ flex-shrink: 0;
1271
+ `;
1272
+ // 호버 효과
1273
+ dot.addEventListener('mouseenter', () => {
1274
+ if (!dot.classList.contains('active')) {
1275
+ dot.style.borderColor = '#999999';
1276
+ dot.style.opacity = '0.9';
1277
+ }
1278
+ });
1279
+ dot.addEventListener('mouseleave', () => {
1280
+ if (!dot.classList.contains('active')) {
1281
+ dot.style.borderColor = '#cccccc';
1282
+ dot.style.opacity = '0.7';
1283
+ }
1284
+ });
1285
+ dotContainer.appendChild(dot);
1286
+ }
1287
+ return dotContainer;
1288
+ }
1289
+ /**
1290
+ * 터치 제스처 지원 추가
1291
+ */
1292
+ static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides, handleInfiniteLoop) {
1293
+ let startX = 0;
1294
+ let isDragging = false;
1295
+ container.addEventListener('touchstart', (e) => {
1296
+ startX = e.touches[0].clientX;
1297
+ isDragging = true;
1298
+ });
1299
+ container.addEventListener('touchmove', (e) => {
1300
+ if (!isDragging)
1301
+ return;
1302
+ e.preventDefault();
1303
+ });
1304
+ container.addEventListener('touchend', (e) => {
1305
+ if (!isDragging)
1306
+ return;
1307
+ isDragging = false;
1308
+ const endX = e.changedTouches[0].clientX;
1309
+ const diff = startX - endX;
1310
+ if (Math.abs(diff) > 50) { // 50px 이상 스와이프 시
1311
+ const currentSlide = getCurrentSlide();
1312
+ if (diff > 0) {
1313
+ // 왼쪽으로 스와이프 (다음 슬라이드)
1314
+ const nextIndex = currentSlide + 1;
1315
+ moveToSlide(nextIndex);
1316
+ if (handleInfiniteLoop) {
1317
+ handleInfiniteLoop();
1318
+ }
1319
+ }
1320
+ else {
1321
+ // 오른쪽으로 스와이프 (이전 슬라이드)
1322
+ const prevIndex = currentSlide > 0 ? currentSlide - 1 : totalSlides - 1;
1323
+ moveToSlide(prevIndex);
1324
+ }
1325
+ }
1326
+ });
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * 단순한 VIEWABLE 이벤트 중복 방지 관리 클래스
1332
+ * - 세션당 동일 광고 1회만 VIEWABLE 이벤트 허용
1333
+ * - 메모리 기반 추적으로 단순화
1334
+ */
1335
+ class ViewableEventTracker {
1336
+ /**
1337
+ * 중복 viewable 이벤트 여부 확인
1338
+ */
1339
+ static isDuplicateViewable(adId, slotId, debug = false) {
1340
+ const key = `${adId}_${slotId}`;
1341
+ // 이미 VIEWABLE 이벤트가 발생한 광고인지 확인
1342
+ if (ViewableEventTracker.viewableTracker.has(key)) {
1343
+ if (debug) {
1344
+ console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
1345
+ }
1346
+ return true;
1347
+ }
1348
+ // 새로운 VIEWABLE 이벤트 기록
1349
+ ViewableEventTracker.viewableTracker.add(key);
1350
+ if (debug) {
1351
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
1352
+ }
1353
+ return false;
1354
+ }
1355
+ /**
1356
+ * 모든 추적 데이터 정리 (디버그용)
1357
+ */
1358
+ static clear() {
1359
+ ViewableEventTracker.viewableTracker.clear();
1360
+ }
1361
+ /**
1362
+ * 특정 광고의 viewable 추적 초기화 (디버그용)
1363
+ */
1364
+ static clearAdViewable(adId, slotId) {
1365
+ const key = `${adId}_${slotId}`;
1366
+ ViewableEventTracker.viewableTracker.delete(key);
1367
+ }
1368
+ }
1369
+ ViewableEventTracker.viewableTracker = new Set();
1370
+
1371
+ /**
1372
+ * 베이스 광고 렌더러 - 공통 기능 구현
1373
+ */
1374
+ class BaseAdRenderer {
1375
+ constructor(adType, debug = false, advertisementEventTracker) {
1376
+ this.adType = adType;
1377
+ this.debug = debug;
1378
+ this.advertisementEventTracker = advertisementEventTracker || null;
1379
+ }
1380
+ /**
1381
+ * Placeholder(슬롯 컨테이너) 생성 - 공통 구현
1382
+ */
1383
+ createPlaceholder(container, slotId, options, config) {
1384
+ const adElement = document.createElement('div');
1385
+ adElement.id = slotId;
1386
+ adElement.className = `adstage-slot adstage-${String(this.adType).toLowerCase()}`;
1387
+ adElement.setAttribute('data-adstage-container', 'true');
1388
+ adElement.setAttribute('data-adstage-type', String(this.adType));
1389
+ adElement.setAttribute('data-adstage-slot', slotId);
1390
+ const { width, height } = this.calculateAdSize(container, options, config) || {
1391
+ width: '100%',
1392
+ height: this.getDefaultHeight()
1393
+ };
1394
+ adElement.style.width = width;
1395
+ adElement.style.height = height;
1396
+ // 플레이스홀더 스타일 모드 결정
1397
+ const placeholderMode = config?.placeholderMode || options.placeholderMode || 'invisible';
1398
+ this.applyPlaceholderStyle(adElement, placeholderMode);
1399
+ container.appendChild(adElement);
1400
+ if (this.debug) {
1401
+ console.log(`📦 Placeholder created for ${this.adType} slot: ${slotId} (${width} x ${height}) - Mode: ${placeholderMode}`);
1402
+ }
1403
+ }
1404
+ /**
1405
+ * 플레이스홀더 스타일 적용
1406
+ */
1407
+ applyPlaceholderStyle(element, mode) {
1408
+ switch (mode) {
1409
+ case 'invisible':
1410
+ // 완전히 투명한 플레이스홀더
1411
+ element.style.backgroundColor = 'transparent';
1412
+ element.style.border = 'none';
1413
+ element.style.opacity = '0';
1414
+ element.innerHTML = '';
1415
+ break;
1416
+ case 'transparent':
1417
+ // 투명하지만 공간은 차지
1418
+ element.style.backgroundColor = 'transparent';
1419
+ element.style.border = 'none';
1420
+ element.style.display = 'block';
1421
+ element.innerHTML = '';
1422
+ break;
1423
+ case 'subtle':
1424
+ // 매우 은은한 표시
1425
+ element.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
1426
+ element.style.border = 'none';
1427
+ element.style.borderRadius = '4px';
1428
+ element.style.display = 'flex';
1429
+ element.style.alignItems = 'center';
1430
+ element.style.justifyContent = 'center';
1431
+ element.innerHTML = '<span style="color: rgba(0, 0, 0, 0.3); font-size: 11px; font-family: sans-serif;">•••</span>';
1432
+ break;
1433
+ case 'minimal':
1434
+ // 최소한의 표시 (기본값)
1435
+ element.style.backgroundColor = 'rgba(248, 249, 250, 0.5)';
1436
+ element.style.border = '1px solid rgba(0, 0, 0, 0.08)';
1437
+ element.style.borderRadius = '6px';
1438
+ element.style.display = 'flex';
1439
+ element.style.alignItems = 'center';
1440
+ element.style.justifyContent = 'center';
1441
+ element.innerHTML = '<span style="color: rgba(0, 0, 0, 0.4); font-size: 12px; font-family: -apple-system, sans-serif;">•••</span>';
1442
+ break;
1443
+ case 'debug':
1444
+ // 개발/디버그용 명확한 표시
1445
+ element.style.border = '2px dashed #e74c3c';
1446
+ element.style.display = 'flex';
1447
+ element.style.alignItems = 'center';
1448
+ element.style.justifyContent = 'center';
1449
+ element.style.backgroundColor = 'rgba(231, 76, 60, 0.1)';
1450
+ element.style.color = '#e74c3c';
1451
+ element.style.fontFamily = 'monospace';
1452
+ element.style.fontSize = '11px';
1453
+ element.innerHTML = `<span>Loading ${this.adType} ad...</span>`;
1454
+ break;
1455
+ default:
1456
+ // 기존 스타일 (legacy)
1457
+ element.style.border = '1px dashed #ccc';
1458
+ element.style.display = 'flex';
1459
+ element.style.alignItems = 'center';
1460
+ element.style.justifyContent = 'center';
1461
+ element.style.backgroundColor = '#f9f9f9';
1462
+ element.style.color = '#666';
1463
+ element.innerHTML = `<span>Loading ${this.adType} ad...</span>`;
1464
+ }
1465
+ }
1466
+ /**
1467
+ * 광고 크기 계산 - 공통 구현
1468
+ */
1469
+ calculateAdSize(container, options, config) {
1470
+ // 사용자가 명시적으로 크기를 지정한 경우
1471
+ const explicitWidth = options.width;
1472
+ const explicitHeight = options.height;
1473
+ // 너비 처리
1474
+ let width;
1475
+ if (typeof explicitWidth === 'number') {
1476
+ width = `${explicitWidth}px`;
1477
+ }
1478
+ else if (typeof explicitWidth === 'string') {
1479
+ width = explicitWidth;
1480
+ }
1481
+ else {
1482
+ width = '100%'; // 기본값은 100%
1483
+ }
1484
+ // 높이 처리 - 핵심 로직
1485
+ let height;
1486
+ if (typeof explicitHeight === 'number') {
1487
+ height = `${explicitHeight}px`;
1488
+ }
1489
+ else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
1490
+ // 명시적인 크기 문자열 (예: '200px', '50vh' 등)
1491
+ height = explicitHeight;
1492
+ }
1493
+ else {
1494
+ // 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
1495
+ const containerHeight = this.getContainerHeight(container);
1496
+ if (containerHeight > 0) {
1497
+ // 컨테이너에 높이가 있으면 100% 사용
1498
+ height = '100%';
1499
+ if (config?.debug || this.debug) {
1500
+ console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
1501
+ }
1502
+ }
1503
+ else {
1504
+ // 컨테이너에 높이가 없으면 타입별 기본값 사용
1505
+ height = this.getDefaultHeight();
1506
+ if (config?.debug || this.debug) {
1507
+ console.log(`📏 Using default height ${height} for ${this.adType}`);
1508
+ }
1509
+ }
1510
+ }
1511
+ return { width, height };
1512
+ }
1513
+ /**
1514
+ * 컨테이너의 실제 높이 계산 - 공통 구현
1515
+ */
1516
+ getContainerHeight(container) {
1517
+ const computedStyle = window.getComputedStyle(container);
1518
+ const height = parseFloat(computedStyle.height);
1519
+ if (!height || height === 0) {
1520
+ const minHeight = parseFloat(computedStyle.minHeight);
1521
+ if (minHeight > 0)
1522
+ return minHeight;
1523
+ if (container.style.height && container.style.height !== 'auto') {
1524
+ const styleHeight = parseFloat(container.style.height);
1525
+ if (styleHeight > 0)
1526
+ return styleHeight;
1527
+ }
1528
+ const heightAttr = container.getAttribute('height');
1529
+ if (heightAttr) {
1530
+ const attrHeight = parseFloat(heightAttr);
1531
+ if (attrHeight > 0)
1532
+ return attrHeight;
1533
+ }
1534
+ }
1535
+ return height || 0;
1536
+ }
1537
+ /**
1538
+ * 이벤트 트래킹 콜백 생성 - 공통 구현
1539
+ */
1540
+ createEventTrackingCallback() {
1541
+ return async (adId, slotId, eventType) => {
1542
+ if (eventType === AdEventType.VIEWABLE) {
1543
+ if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
1544
+ if (this.debug) {
1545
+ console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (this.debug) {
1550
+ console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
1551
+ }
1552
+ }
1553
+ if (this.advertisementEventTracker) {
1554
+ try {
1555
+ if (this.debug) {
1556
+ console.log(`🔄 Starting advertisement event tracking: ${eventType} for ad ${adId} in slot ${slotId}`);
1557
+ }
1558
+ await this.advertisementEventTracker.trackAdvertisementEvent(adId, slotId, eventType);
1559
+ if (this.debug) {
1560
+ console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
1561
+ }
1562
+ }
1563
+ catch (error) {
1564
+ if (this.debug) {
1565
+ console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
1566
+ }
1567
+ }
1568
+ }
1569
+ else {
1570
+ if (this.debug) {
1571
+ console.warn(`⚠️ AdvertisementEventTracker not available for ${eventType} event`);
1572
+ }
1573
+ }
1574
+ };
1575
+ }
1576
+ /**
1577
+ * Fallback 광고 렌더링 - 공통 구현
1578
+ */
1579
+ renderFallback(slot) {
1580
+ const element = document.getElementById(slot.id);
1581
+ if (element) {
1582
+ const adstageContainers = [
1583
+ element.querySelector('[data-adstage-container="true"]'),
1584
+ element.closest('[data-adstage-container="true"]'),
1585
+ element
1586
+ ].filter(el => el && el.hasAttribute('data-adstage-container'));
1587
+ const classBasedContainers = [
1588
+ element.closest('.adstage-slot'),
1589
+ element.closest(`.adstage-${String(this.adType).toLowerCase()}`),
1590
+ element.closest('[class*="ad"]'),
1591
+ element.closest('[class*="banner"]'),
1592
+ element.closest('[class*="container"]'),
1593
+ element.closest('div[style*="height"]'),
1594
+ element.closest('div[style*="min-height"]'),
1595
+ element.parentElement
1596
+ ].filter(Boolean);
1597
+ const possibleContainers = [...adstageContainers, ...classBasedContainers];
1598
+ const targetContainer = possibleContainers[0];
1599
+ if (targetContainer) {
1600
+ let containerType = 'unknown';
1601
+ if (targetContainer.hasAttribute('data-adstage-container')) {
1602
+ containerType = 'adstage-official';
1603
+ }
1604
+ else if (targetContainer.classList.contains('adstage-slot')) {
1605
+ containerType = 'adstage-class';
1606
+ }
1607
+ else {
1608
+ containerType = 'generic';
1609
+ }
1610
+ targetContainer.style.cssText += `
1611
+ height: 0px !important;
1612
+ min-height: 0px !important;
1613
+ padding: 0px !important;
1614
+ margin: 0px !important;
1615
+ border: none !important;
1616
+ overflow: hidden !important;
1617
+ display: block !important;
1618
+ `;
1619
+ targetContainer.innerHTML = '';
1620
+ targetContainer.setAttribute('data-adstage-empty', 'true');
1621
+ if (this.debug) {
1622
+ console.warn(`⚠️ ${this.adType} container collapsed (${containerType}): ${slot.id}`, targetContainer);
1623
+ }
1624
+ }
1625
+ else {
1626
+ this.createEmptyContainer(slot);
1627
+ }
1628
+ }
1629
+ slot.advertisement = undefined;
1630
+ slot.isEmpty = true;
1631
+ }
1632
+ /**
1633
+ * 빈 컨테이너 생성 - 공통 구현
1634
+ */
1635
+ createEmptyContainer(slot) {
1636
+ const originalContainer = document.getElementById(slot.containerId);
1637
+ if (originalContainer) {
1638
+ originalContainer.innerHTML = '';
1639
+ const emptyElement = document.createElement('div');
1640
+ emptyElement.id = slot.id;
1641
+ emptyElement.className = `adstage-slot adstage-empty adstage-${String(this.adType).toLowerCase()}`;
1642
+ emptyElement.setAttribute('data-adstage-container', 'true');
1643
+ emptyElement.setAttribute('data-adstage-empty', 'true');
1644
+ emptyElement.setAttribute('data-adstage-slot', slot.id);
1645
+ emptyElement.style.cssText = `
1646
+ height: 0px !important;
1647
+ min-height: 0px !important;
1648
+ padding: 0px !important;
1649
+ margin: 0px !important;
1650
+ border: none !important;
1651
+ overflow: hidden !important;
1652
+ display: block !important;
1653
+ `;
1654
+ originalContainer.appendChild(emptyElement);
1655
+ if (this.debug) {
1656
+ console.warn(`⚠️ Created empty ${this.adType} container: ${slot.id}`);
1657
+ }
1658
+ }
1659
+ }
1660
+ /**
1661
+ * 디버그 로그 출력 - 공통 구현
1662
+ */
1663
+ log(message, ...args) {
1664
+ if (this.debug) {
1665
+ console.log(`[${this.adType}] ${message}`, ...args);
1666
+ }
1667
+ }
1668
+ }
1669
+
1670
+ /**
1671
+ import { AdSlot, Advertisement, AdType } from '../../../types/advertisement';
1672
+ import { AdvertisementEventTracker } from '../../../managers/ads/advertisement-event-tracker';
1673
+ import { CarouselSliderManager } from '../../../managers/ads/carousel-slider-manager';
1674
+ import { BaseAdRenderer } from './base-ad-renderer';
1675
+ import { AdRenderOptions } from '../interfaces/i-ad-renderer'; 광고 전용 렌더러
1676
+ */
1677
+ class BannerAdRenderer extends BaseAdRenderer {
1678
+ constructor(debug = false, advertisementEventTracker) {
1679
+ super(AdType.BANNER, debug, advertisementEventTracker);
1680
+ }
1681
+ /**
1682
+ * 배너 광고 기본 높이
1683
+ */
1684
+ getDefaultHeight() {
1685
+ return '250px';
1686
+ }
1687
+ /**
1688
+ * 단일 배너 광고 렌더링
1689
+ */
1690
+ async renderAdElement(slot, advertisement) {
1691
+ const container = document.getElementById(slot.containerId);
1692
+ if (!container)
1693
+ return;
1694
+ const adElement = document.createElement('div');
1695
+ adElement.className = 'adstage-ad adstage-banner-ad';
1696
+ const optimizedHeight = slot.optimizedHeight;
1697
+ const containerElement = container.parentElement || container;
1698
+ if (optimizedHeight) {
1699
+ adElement.style.width = '100%';
1700
+ adElement.style.height = String(optimizedHeight);
1701
+ }
1702
+ else {
1703
+ const config = slot.config;
1704
+ const options = {
1705
+ width: config?.width,
1706
+ height: config?.height
1707
+ };
1708
+ const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
1709
+ adElement.style.width = width;
1710
+ adElement.style.height = height;
1711
+ }
1712
+ if (advertisement.imageUrl) {
1713
+ await this.renderOptimizedBannerImage(adElement, advertisement, slot);
1714
+ }
1715
+ else {
1716
+ adElement.innerHTML = `<div>${advertisement.title || 'Banner Ad'}</div>`;
1717
+ }
1718
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1719
+ AdClickHandler.addClickEventForRenderer(adElement, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1720
+ container.innerHTML = '';
1721
+ container.appendChild(adElement);
1722
+ }
1723
+ /**
1724
+ * 다중 배너 광고 렌더링 (슬라이더)
1725
+ */
1726
+ async renderMultipleAds(slot, advertisements) {
1727
+ const container = document.getElementById(slot.containerId);
1728
+ if (!container) {
1729
+ throw new Error(`Container not found: ${slot.containerId}`);
1730
+ }
1731
+ // 배너 광고를 위한 컨테이너 최적화
1732
+ await this.optimizeContainerForBannerAds(slot, advertisements);
1733
+ const trackEventCallback = this.createEventTrackingCallback();
1734
+ const optimizedSliderOptions = {
1735
+ autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
1736
+ ...slot.config,
1737
+ optimizedHeight: slot.optimizedHeight,
1738
+ aspectRatio: slot.aspectRatio
1739
+ };
1740
+ const sliderElement = CarouselSliderManager.createSliderContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback, this.debug);
1741
+ if (sliderElement) {
1742
+ container.innerHTML = '';
1743
+ container.appendChild(sliderElement);
1744
+ if (this.debug) {
1745
+ console.log(`🎠 Banner carousel created for slot: ${slot.id} with ${advertisements.length} ads (optimized: ${slot.optimizedHeight || 'default'})`);
1746
+ }
1747
+ }
1748
+ }
1749
+ /**
1750
+ * 여러 광고의 최적 컨테이너 크기 계산
1751
+ */
1752
+ async calculateOptimalContainerSize(advertisements, containerWidth) {
1753
+ if (!advertisements.length) {
1754
+ return {
1755
+ width: '100%',
1756
+ height: this.getDefaultHeight(),
1757
+ aspectRatio: 16 / 9
1758
+ };
1759
+ }
1760
+ try {
1761
+ const imageDimensions = await Promise.allSettled(advertisements
1762
+ .filter(ad => ad.imageUrl)
1763
+ .map(ad => this.loadImageDimensions(ad.imageUrl)));
1764
+ const validDimensions = imageDimensions
1765
+ .filter((result) => result.status === 'fulfilled')
1766
+ .map(result => result.value);
1767
+ if (validDimensions.length === 0) {
1768
+ return {
1769
+ width: '100%',
1770
+ height: this.getDefaultHeight(),
1771
+ aspectRatio: 16 / 9
1772
+ };
1773
+ }
1774
+ const strategy = this.selectOptimalSizeStrategy(validDimensions);
1775
+ const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
1776
+ if (this.debug) {
1777
+ console.log(`📐 Optimal banner container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
1778
+ }
1779
+ return {
1780
+ width: '100%',
1781
+ height: `${optimalHeight}px`,
1782
+ aspectRatio: containerWidth / optimalHeight
1783
+ };
1784
+ }
1785
+ catch (error) {
1786
+ console.warn('Failed to calculate optimal banner size, using defaults:', error);
1787
+ return {
1788
+ width: '100%',
1789
+ height: this.getDefaultHeight(),
1790
+ aspectRatio: 16 / 9
1791
+ };
1792
+ }
1793
+ }
1794
+ /**
1795
+ * 배너 광고를 위한 컨테이너 최적화
1796
+ */
1797
+ async optimizeContainerForBannerAds(slot, advertisements) {
1798
+ try {
1799
+ const container = document.getElementById(slot.containerId);
1800
+ const adElement = document.getElementById(slot.id);
1801
+ if (!container || !adElement)
1802
+ return;
1803
+ const containerWidth = container.getBoundingClientRect().width || 300;
1804
+ const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth);
1805
+ adElement.style.height = optimalSize.height;
1806
+ slot.optimizedHeight = optimalSize.height;
1807
+ slot.aspectRatio = optimalSize.aspectRatio;
1808
+ if (this.debug) {
1809
+ console.log(`🔧 Banner container optimized for ${advertisements.length} ads: ${optimalSize.height}`);
1810
+ }
1811
+ }
1812
+ catch (error) {
1813
+ console.warn('Banner container optimization failed, using default size:', error);
1814
+ }
1815
+ }
1816
+ /**
1817
+ * 최적 크기 조정 전략 선택
1818
+ */
1819
+ selectOptimalSizeStrategy(dimensions) {
1820
+ const aspectRatios = dimensions.map(d => d.width / d.height);
1821
+ const ratioGroups = new Map();
1822
+ aspectRatios.forEach(ratio => {
1823
+ const roundedRatio = Math.round(ratio * 10) / 10;
1824
+ const key = roundedRatio.toString();
1825
+ ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
1826
+ });
1827
+ const maxGroup = Math.max(...ratioGroups.values());
1828
+ const totalImages = dimensions.length;
1829
+ if (maxGroup / totalImages >= 0.7) {
1830
+ return 'dominant';
1831
+ }
1832
+ const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
1833
+ const standardCount = aspectRatios.filter(ratio => standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)).length;
1834
+ if (standardCount / totalImages >= 0.5) {
1835
+ return 'common';
1836
+ }
1837
+ return 'average';
1838
+ }
1839
+ /**
1840
+ * 전략에 따른 최적 높이 계산
1841
+ */
1842
+ calculateOptimalHeight(dimensions, containerWidth, strategy) {
1843
+ const aspectRatios = dimensions.map(d => d.width / d.height);
1844
+ switch (strategy) {
1845
+ case 'dominant': {
1846
+ const ratioGroups = new Map();
1847
+ aspectRatios.forEach(ratio => {
1848
+ const roundedRatio = Math.round(ratio * 10) / 10;
1849
+ const key = roundedRatio.toString();
1850
+ const existing = ratioGroups.get(key);
1851
+ if (existing) {
1852
+ existing.count++;
1853
+ }
1854
+ else {
1855
+ ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
1856
+ }
1857
+ });
1858
+ const dominantGroup = Array.from(ratioGroups.values()).reduce((max, current) => current.count > max.count ? current : max);
1859
+ return Math.round(containerWidth / dominantGroup.ratio);
1860
+ }
1861
+ case 'common': {
1862
+ const standardRatios = [
1863
+ { ratio: 16 / 9, name: '16:9' },
1864
+ { ratio: 4 / 3, name: '4:3' },
1865
+ { ratio: 1 / 1, name: '1:1' },
1866
+ { ratio: 3 / 2, name: '3:2' }
1867
+ ];
1868
+ const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1869
+ const bestStandard = standardRatios.reduce((best, current) => Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best);
1870
+ if (this.debug) {
1871
+ console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
1872
+ }
1873
+ return Math.round(containerWidth / bestStandard.ratio);
1874
+ }
1875
+ case 'average':
1876
+ default: {
1877
+ const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
1878
+ return Math.round(containerWidth / averageRatio);
1879
+ }
1880
+ }
1881
+ }
1882
+ /**
1883
+ * 이미지 로드 및 실제 크기 획득
1884
+ */
1885
+ loadImageDimensions(imageUrl) {
1886
+ return new Promise((resolve, reject) => {
1887
+ const img = new Image();
1888
+ img.onload = () => {
1889
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
1890
+ };
1891
+ img.onerror = () => {
1892
+ reject(new Error(`Failed to load image: ${imageUrl}`));
1893
+ };
1894
+ img.src = imageUrl;
1895
+ });
1896
+ }
1897
+ /**
1898
+ * 이미지와 컨테이너 비율을 고려한 최적화 스타일 적용
1899
+ */
1900
+ applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio) {
1901
+ const ratio = imageAspectRatio / containerAspectRatio;
1902
+ if (Math.abs(ratio - 1) < 0.1) {
1903
+ img.style.objectFit = 'cover';
1904
+ img.style.objectPosition = 'center';
1905
+ }
1906
+ else if (ratio > 1.3) {
1907
+ img.style.objectFit = 'contain';
1908
+ img.style.objectPosition = 'center';
1909
+ img.style.backgroundColor = '#f0f0f0';
1910
+ }
1911
+ else if (ratio < 0.7) {
1912
+ img.style.objectFit = 'cover';
1913
+ img.style.objectPosition = 'center';
1914
+ }
1915
+ else {
1916
+ img.style.objectFit = 'cover';
1917
+ img.style.objectPosition = 'center';
1918
+ }
1919
+ if (this.debug) {
1920
+ console.log(`🎨 Banner image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
1921
+ }
1922
+ }
1923
+ /**
1924
+ * 배너 이미지 최적화 렌더링 - public으로 변경
1925
+ */
1926
+ async renderOptimizedBannerImage(container, advertisement, slot) {
1927
+ const img = document.createElement('img');
1928
+ img.style.width = '100%';
1929
+ img.style.height = '100%';
1930
+ img.style.display = 'block';
1931
+ img.style.borderRadius = '8px';
1932
+ img.alt = advertisement.title || 'Banner Advertisement';
1933
+ container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #999;">Loading...</div>';
1934
+ try {
1935
+ if (!advertisement.imageUrl) {
1936
+ throw new Error('Image URL is not provided');
1937
+ }
1938
+ const imageDimensions = await this.loadImageDimensions(advertisement.imageUrl);
1939
+ const containerRect = container.getBoundingClientRect();
1940
+ const containerWidth = containerRect.width;
1941
+ const containerHeight = containerRect.height;
1942
+ if (this.debug) {
1943
+ console.log(`📸 Banner image dimensions: ${imageDimensions.width}x${imageDimensions.height}`);
1944
+ console.log(`📦 Banner container dimensions: ${containerWidth}x${containerHeight}`);
1945
+ }
1946
+ const imageAspectRatio = imageDimensions.width / imageDimensions.height;
1947
+ const containerAspectRatio = containerWidth / containerHeight;
1948
+ this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
1949
+ img.src = advertisement.imageUrl;
1950
+ container.innerHTML = '';
1951
+ container.appendChild(img);
1952
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1953
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1954
+ if (this.debug) {
1955
+ console.log(`✅ Optimized banner image rendered for ad: ${advertisement._id}`);
1956
+ }
1957
+ return img;
1958
+ }
1959
+ catch (error) {
1960
+ console.error('❌ Failed to load optimized banner image:', error);
1961
+ if (advertisement.imageUrl) {
1962
+ img.src = advertisement.imageUrl;
1963
+ img.style.objectFit = 'cover';
1964
+ img.style.objectPosition = 'center';
1965
+ container.innerHTML = '';
1966
+ container.appendChild(img);
1967
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
1968
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Banner');
1969
+ }
1970
+ return img;
1971
+ }
1972
+ }
1973
+ }
1974
+
1975
+ /**
1976
+ * 텍스트 광고 공통 유틸리티 함수들
1977
+ */
1978
+ class TextAdUtils {
1979
+ /**
1980
+ * 텍스트 광고의 기본 스타일을 생성
1981
+ * @param isSimple 간단한 스타일 여부
1982
+ * @returns CSS 스타일 문자열
1983
+ */
1984
+ static createTextAdStyles(isSimple = false) {
1985
+ const baseStyles = `
1986
+ font-family: 'Arial', sans-serif;
1987
+ text-decoration: none;
1988
+ display: block;
1989
+ text-align: left;
1990
+ transition: color 0.3s ease;
1991
+ width: 100%;
1992
+ box-sizing: border-box;
1993
+ overflow: visible;
1994
+ padding-right: 2px;
1995
+ padding-left: 2px;
1996
+ white-space: nowrap;
1997
+ `;
1998
+ return baseStyles;
1999
+ }
2000
+ /**
2001
+ * 텍스트 광고의 콘텐츠를 설정 (textContent 우선, 없으면 title)
2002
+ * @param element 설정할 HTML 요소
2003
+ * @param adData 광고 데이터
2004
+ */
2005
+ static setTextAdContent(element, adData) {
2006
+ const content = adData.textContent || adData.title || '';
2007
+ element.textContent = content;
2008
+ }
2009
+ }
2010
+
2011
+ /**
2012
+ * 텍스트 전환 효과 관리 클래스
2013
+ * - 텍스트 광고 전용 페이드 인/아웃 + 상하 움직임 효과
2014
+ * - 부드러운 전환 애니메이션 (vertical transition)
2015
+ * - 무한 루프 지원
2016
+ */
2017
+ class TextTransitionManager {
2018
+ /**
2019
+ * 간단한 광고 요소 생성 (크기 측정용)
2020
+ */
2021
+ static createSimpleAdElement(slot, advertisement) {
2022
+ const adElement = document.createElement('div');
2023
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
2024
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
2025
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
2026
+ // 🎯 공통 스타일 및 콘텐츠 적용 (중복 제거)
2027
+ adElement.style.cssText = TextAdUtils.createTextAdStyles(true);
2028
+ TextAdUtils.setTextAdContent(adElement, advertisement);
2029
+ return adElement;
2030
+ }
2031
+ /**
2032
+ * 텍스트 전환 컨테이너 생성
2033
+ */
2034
+ static createTextTransitionContainer(slot, advertisements, options, trackEventCallback, debug = false) {
2035
+ // 사용자가 높이를 지정했는지 미리 확인 ('auto'나 undefined면 자동 높이)
2036
+ const hasUserDefinedHeight = slot.height && slot.height !== 0 && slot.height !== 'auto';
2037
+ const sliderWrapper = document.createElement('div');
2038
+ sliderWrapper.className = 'adstage-fade-slider-wrapper';
2039
+ // 래퍼 스타일 설정
2040
+ const containerStyles = {
2041
+ position: 'relative',
2042
+ overflow: 'hidden',
2043
+ display: 'block',
2044
+ };
2045
+ // 높이가 지정되지 않은 경우 자동 높이 사용
2046
+ if (!hasUserDefinedHeight) {
2047
+ containerStyles.height = 'auto';
2048
+ containerStyles.minHeight = 'fit-content';
2049
+ }
2050
+ // 사용자가 크기를 지정한 경우
2051
+ if (slot.width && slot.width !== 0) {
2052
+ let width;
2053
+ if (typeof slot.width === 'string') {
2054
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
2055
+ }
2056
+ else {
2057
+ width = `${slot.width}px`;
2058
+ }
2059
+ containerStyles.width = width;
2060
+ }
2061
+ // 사용자가 높이를 명시적으로 지정한 경우에만 적용 ('auto'는 제외)
2062
+ if (slot.height && slot.height !== 0 && slot.height !== 'auto' && hasUserDefinedHeight) {
2063
+ let height;
2064
+ if (typeof slot.height === 'string') {
2065
+ height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
2066
+ }
2067
+ else {
2068
+ height = `${slot.height}px`;
2069
+ }
2070
+ containerStyles.height = height;
2071
+ }
2072
+ // 스타일 적용
2073
+ Object.entries(containerStyles).forEach(([key, value]) => {
2074
+ sliderWrapper.style.setProperty(key, value);
2075
+ });
2076
+ // 슬라이드 컨테이너
2077
+ const slideContainer = document.createElement('div');
2078
+ slideContainer.className = 'adstage-fade-slide-container';
2079
+ slideContainer.style.cssText = `
2080
+ position: relative;
2081
+ width: 100%;
2082
+ ${hasUserDefinedHeight ? 'height: 100%;' : 'height: auto; min-height: fit-content;'}
2083
+ `;
2084
+ // 크기 측정을 위한 임시 컨테이너 (자동 크기 계산이 필요한 경우)
2085
+ let measureContainer = null;
2086
+ const needsWidthMeasurement = !slot.width || slot.width === 0;
2087
+ const needsHeightMeasurement = !slot.height || slot.height === 0 || slot.height === undefined || slot.height === 'auto';
2088
+ // 너비 측정만 필요한 경우 (높이는 자동으로 유지)
2089
+ if (needsWidthMeasurement || (needsHeightMeasurement && hasUserDefinedHeight)) {
2090
+ measureContainer = document.createElement('div');
2091
+ measureContainer.style.cssText = `
2092
+ position: absolute;
2093
+ visibility: hidden;
2094
+ white-space: nowrap;
2095
+ top: -9999px;
2096
+ left: -9999px;
2097
+ `;
2098
+ // width가 설정되어 있으면 측정 컨테이너에도 적용
2099
+ if (!needsWidthMeasurement && slot.width) {
2100
+ let width;
2101
+ if (typeof slot.width === 'string') {
2102
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
2103
+ }
2104
+ else {
2105
+ width = `${slot.width}px`;
2106
+ }
2107
+ measureContainer.style.width = width;
2108
+ measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
2109
+ }
2110
+ document.body.appendChild(measureContainer);
2111
+ let maxWidth = 0;
2112
+ let maxHeight = 0;
2113
+ // 모든 광고의 크기를 측정하여 최대 크기 찾기
2114
+ advertisements.forEach(ad => {
2115
+ const measureAdElement = this.createSimpleAdElement(slot, ad);
2116
+ measureContainer.appendChild(measureAdElement);
2117
+ const rect = measureAdElement.getBoundingClientRect();
2118
+ if (rect.width > maxWidth)
2119
+ maxWidth = rect.width;
2120
+ if (rect.height > maxHeight)
2121
+ maxHeight = rect.height;
2122
+ // 측정 후 요소 제거
2123
+ measureContainer.removeChild(measureAdElement);
2124
+ });
2125
+ // 측정된 최대 크기로 래퍼 크기 설정
2126
+ if (needsWidthMeasurement && maxWidth > 0) {
2127
+ sliderWrapper.style.width = `${maxWidth}px`;
2128
+ }
2129
+ // 사용자가 높이를 지정하지 않은 경우 auto 높이 유지 (측정된 높이 적용하지 않음)
2130
+ if (needsHeightMeasurement && maxHeight > 0 && hasUserDefinedHeight) {
2131
+ sliderWrapper.style.height = `${maxHeight}px`;
2132
+ }
2133
+ // 측정 컨테이너 제거
2134
+ document.body.removeChild(measureContainer);
2135
+ }
2136
+ // 각 광고를 슬라이드로 생성
2137
+ const slideElements = [];
2138
+ advertisements.forEach((ad, index) => {
2139
+ const slideElement = document.createElement('div');
2140
+ slideElement.className = 'adstage-fade-slide';
2141
+ // 자동 높이일 때는 첫 번째 슬라이드만 relative로 높이 확보
2142
+ const isFirstSlide = index === 0;
2143
+ const positionStyle = hasUserDefinedHeight ? 'absolute' : (isFirstSlide ? 'relative' : 'absolute');
2144
+ slideElement.style.cssText = `
2145
+ position: ${positionStyle};
2146
+ top: ${positionStyle === 'absolute' ? '0' : 'auto'};
2147
+ left: ${positionStyle === 'absolute' ? '0' : 'auto'};
2148
+ width: 100%;
2149
+ ${hasUserDefinedHeight ? 'height: 100%;' : 'height: auto;'}
2150
+ display: flex;
2151
+ align-items: center;
2152
+ justify-content: flex-start;
2153
+ opacity: ${index === 0 ? '1' : '0'};
2154
+ transform: translateY(${index === 0 ? '0' : '20px'});
2155
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
2156
+ z-index: ${index === 0 ? '2' : '1'};
2157
+ `;
2158
+ // 광고 렌더링 - 공통 함수 사용으로 일관성 유지
2159
+ const adElement = this.createSimpleAdElement(slot, ad);
2160
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2161
+ AdClickHandler.addClickEventForSlider(adElement, ad, slot, trackEventCallback, debug, 'Text');
2162
+ slideElement.appendChild(adElement);
2163
+ slideContainer.appendChild(slideElement);
2164
+ slideElements.push(slideElement);
2165
+ });
2166
+ // 슬라이더 상태 관리
2167
+ let currentSlide = 0;
2168
+ const totalSlides = advertisements.length;
2169
+ const autoSlideInterval = (options?.autoSlideInterval || 4) * 1000; // 기본 4초 (페이드는 조금 더 길게)
2170
+ // 슬라이드 이동 함수 (페이드 효과)
2171
+ const moveToSlide = (index) => {
2172
+ if (index >= totalSlides) {
2173
+ index = 0; // 무한 루프
2174
+ }
2175
+ else if (index < 0) {
2176
+ index = totalSlides - 1;
2177
+ }
2178
+ const previousSlide = slideElements[currentSlide];
2179
+ const nextSlide = slideElements[index];
2180
+ // 자동 높이 모드일 때는 위치 변경
2181
+ if (!hasUserDefinedHeight) {
2182
+ // 이전 슬라이드를 absolute로 변경
2183
+ if (previousSlide.style.position === 'relative') {
2184
+ previousSlide.style.position = 'absolute';
2185
+ previousSlide.style.top = '0';
2186
+ previousSlide.style.left = '0';
2187
+ }
2188
+ // 다음 슬라이드를 relative로 변경하여 높이 확보
2189
+ nextSlide.style.position = 'relative';
2190
+ nextSlide.style.top = 'auto';
2191
+ nextSlide.style.left = 'auto';
2192
+ }
2193
+ // 이전 슬라이드 페이드 아웃 (아래로)
2194
+ previousSlide.style.opacity = '0';
2195
+ previousSlide.style.transform = 'translateY(-20px)';
2196
+ previousSlide.style.zIndex = '1';
2197
+ // 다음 슬라이드 페이드 인 (위에서)
2198
+ nextSlide.style.opacity = '1';
2199
+ nextSlide.style.transform = 'translateY(0)';
2200
+ nextSlide.style.zIndex = '2';
2201
+ // 다른 슬라이드들은 숨김
2202
+ slideElements.forEach((slide, i) => {
2203
+ if (i !== index && i !== currentSlide) {
2204
+ slide.style.opacity = '0';
2205
+ slide.style.transform = 'translateY(20px)';
2206
+ slide.style.zIndex = '1';
2207
+ // 자동 높이 모드일 때는 다른 슬라이드들을 absolute로
2208
+ if (!hasUserDefinedHeight && slide.style.position === 'relative') {
2209
+ slide.style.position = 'absolute';
2210
+ slide.style.top = '0';
2211
+ slide.style.left = '0';
2212
+ }
2213
+ }
2214
+ });
2215
+ currentSlide = index;
2216
+ // 🎯 공통 슬라이더 이벤트 추적 적용 (모든 슬라이드 포함)
2217
+ SliderEventTracker.trackSlideViewable(advertisements[currentSlide], slot, currentSlide, trackEventCallback, debug // debug 모드 상속
2218
+ );
2219
+ };
2220
+ // 자동 슬라이드
2221
+ let autoSlideTimer = setInterval(() => {
2222
+ const nextIndex = currentSlide + 1;
2223
+ moveToSlide(nextIndex);
2224
+ }, autoSlideInterval);
2225
+ // 마우스 호버 시 자동 슬라이드 일시정지
2226
+ sliderWrapper.addEventListener('mouseenter', () => {
2227
+ clearInterval(autoSlideTimer);
2228
+ });
2229
+ sliderWrapper.addEventListener('mouseleave', () => {
2230
+ autoSlideTimer = setInterval(() => {
2231
+ const nextIndex = currentSlide + 1;
2232
+ moveToSlide(nextIndex);
2233
+ }, autoSlideInterval);
2234
+ });
2235
+ // 터치 제스처 지원
2236
+ TextTransitionManager.addTouchSupport(sliderWrapper, moveToSlide, () => currentSlide, totalSlides);
2237
+ // 요소들 조립
2238
+ sliderWrapper.appendChild(slideContainer);
2239
+ return sliderWrapper;
2240
+ }
2241
+ /**
2242
+ * 터치 제스처 지원 추가
2243
+ */
2244
+ static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides) {
2245
+ let startX = 0;
2246
+ let startY = 0;
2247
+ let isDragging = false;
2248
+ container.addEventListener('touchstart', (e) => {
2249
+ startX = e.touches[0].clientX;
2250
+ startY = e.touches[0].clientY;
2251
+ isDragging = true;
2252
+ });
2253
+ container.addEventListener('touchmove', (e) => {
2254
+ if (!isDragging)
2255
+ return;
2256
+ e.preventDefault();
2257
+ });
2258
+ container.addEventListener('touchend', (e) => {
2259
+ if (!isDragging)
2260
+ return;
2261
+ isDragging = false;
2262
+ const endX = e.changedTouches[0].clientX;
2263
+ const endY = e.changedTouches[0].clientY;
2264
+ const diffX = startX - endX;
2265
+ const diffY = startY - endY;
2266
+ // 가로 스와이프가 세로 스와이프보다 클 때만 처리
2267
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
2268
+ const currentSlide = getCurrentSlide();
2269
+ if (diffX > 0) {
2270
+ // 왼쪽으로 스와이프 (다음 슬라이드)
2271
+ moveToSlide(currentSlide + 1);
2272
+ }
2273
+ else {
2274
+ // 오른쪽으로 스와이프 (이전 슬라이드)
2275
+ moveToSlide(currentSlide - 1);
2276
+ }
2277
+ }
2278
+ });
2279
+ }
2280
+ }
2281
+
2282
+ /**
2283
+ * 텍스트 광고 전용 렌더러
2284
+ */
2285
+ class TextAdRenderer extends BaseAdRenderer {
2286
+ constructor(debug = false, advertisementEventTracker) {
2287
+ super(AdType.TEXT, debug, advertisementEventTracker);
2288
+ }
2289
+ /**
2290
+ * 텍스트 광고 기본 높이
2291
+ */
2292
+ getDefaultHeight() {
2293
+ return '60px';
2294
+ }
2295
+ /**
2296
+ * 단일 텍스트 광고 렌더링
2297
+ */
2298
+ async renderAdElement(slot, advertisement) {
2299
+ const container = document.getElementById(slot.containerId);
2300
+ if (!container)
2301
+ return;
2302
+ const adElement = document.createElement('div');
2303
+ adElement.className = 'adstage-ad adstage-text-ad';
2304
+ const optimizedHeight = slot.optimizedHeight;
2305
+ const containerElement = container.parentElement || container;
2306
+ if (optimizedHeight) {
2307
+ adElement.style.width = '100%';
2308
+ adElement.style.height = String(optimizedHeight);
2309
+ }
2310
+ else {
2311
+ const config = slot.config;
2312
+ const options = {
2313
+ width: config?.width,
2314
+ height: config?.height
2315
+ };
2316
+ const { width, height } = this.calculateAdSize(containerElement, options, { debug: this.debug });
2317
+ adElement.style.width = width;
2318
+ adElement.style.height = height;
2319
+ }
2320
+ // 텍스트 콘텐츠 렌더링
2321
+ const textDiv = document.createElement('div');
2322
+ textDiv.className = 'adstage-text-content';
2323
+ textDiv.style.cssText = TextAdUtils.createTextAdStyles(false);
2324
+ TextAdUtils.setTextAdContent(textDiv, advertisement); // 최대 라인 수 제한
2325
+ const maxLines = slot.config?.maxLines;
2326
+ if (maxLines && typeof maxLines === 'number') {
2327
+ textDiv.style.display = '-webkit-box';
2328
+ textDiv.style.webkitLineClamp = String(maxLines);
2329
+ textDiv.style.webkitBoxOrient = 'vertical';
2330
+ textDiv.style.overflow = 'hidden';
2331
+ }
2332
+ adElement.appendChild(textDiv);
2333
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2334
+ AdClickHandler.addClickEventForRenderer(adElement, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Text');
2335
+ container.innerHTML = '';
2336
+ container.appendChild(adElement);
2337
+ if (this.debug) {
2338
+ console.log(`✨ Single text ad rendered: ${advertisement._id}`);
2339
+ }
2340
+ }
2341
+ /**
2342
+ * 다중 텍스트 광고 렌더링 (전환 효과)
2343
+ */
2344
+ async renderMultipleAds(slot, advertisements) {
2345
+ const container = document.getElementById(slot.containerId);
2346
+ if (!container) {
2347
+ throw new Error(`Container not found: ${slot.containerId}`);
2348
+ }
2349
+ const trackEventCallback = this.createEventTrackingCallback();
2350
+ const optimizedSliderOptions = {
2351
+ autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
2352
+ ...slot.config,
2353
+ optimizedHeight: slot.optimizedHeight,
2354
+ aspectRatio: slot.aspectRatio
2355
+ };
2356
+ const sliderElement = TextTransitionManager.createTextTransitionContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback, this.debug);
2357
+ if (sliderElement) {
2358
+ container.innerHTML = '';
2359
+ container.appendChild(sliderElement);
2360
+ if (this.debug) {
2361
+ console.log(`✨ Text transition created for slot: ${slot.id} with ${advertisements.length} ads`);
2362
+ }
2363
+ }
2364
+ }
2365
+ }
2366
+
2367
+ /**
2368
+ * 비디오 광고 전용 렌더러
2369
+ */
2370
+ class VideoAdRenderer extends BaseAdRenderer {
2371
+ constructor(debug = false, advertisementEventTracker) {
2372
+ super(AdType.VIDEO, debug, advertisementEventTracker);
2373
+ }
2374
+ /**
2375
+ * 비디오 광고 기본 높이 (16:9 비율 고려)
2376
+ */
2377
+ getDefaultHeight() {
2378
+ return '360px';
2379
+ }
2380
+ /**
2381
+ * 단일 비디오 광고 렌더링
2382
+ */
2383
+ async renderAdElement(slot, advertisement) {
2384
+ const container = document.getElementById(slot.containerId);
2385
+ if (!container)
2386
+ return;
2387
+ // 비디오 전용 컨테이너 생성
2388
+ const videoContainer = document.createElement('div');
2389
+ videoContainer.className = 'adstage-video-container';
2390
+ videoContainer.style.cssText = `
2391
+ width: 100%;
2392
+ height: 100%;
2393
+ display: flex;
2394
+ align-items: center;
2395
+ justify-content: center;
2396
+ background: #000;
2397
+ border-radius: 8px;
2398
+ overflow: hidden;
2399
+ `;
2400
+ // 비디오 요소 렌더링
2401
+ this.renderVideoElementDirect(videoContainer, advertisement, slot);
2402
+ container.innerHTML = '';
2403
+ container.appendChild(videoContainer);
2404
+ if (this.debug) {
2405
+ console.log(`🎬 Single video ad rendered: ${advertisement._id}`);
2406
+ }
2407
+ }
2408
+ /**
2409
+ * 다중 비디오 광고 렌더링 - 비디오는 단일 렌더링만 지원
2410
+ */
2411
+ async renderMultipleAds(slot, advertisements) {
2412
+ const container = document.getElementById(slot.containerId);
2413
+ if (!container) {
2414
+ throw new Error(`Container not found: ${slot.containerId}`);
2415
+ }
2416
+ if (advertisements.length > 0) {
2417
+ const videoAd = advertisements[0]; // 첫 번째 비디오만 사용
2418
+ // 비디오 전용 컨테이너 생성
2419
+ const videoContainer = document.createElement('div');
2420
+ videoContainer.className = 'adstage-video-container';
2421
+ videoContainer.style.cssText = `
2422
+ width: 100%;
2423
+ height: 100%;
2424
+ display: flex;
2425
+ align-items: center;
2426
+ justify-content: center;
2427
+ background: #000;
2428
+ border-radius: 8px;
2429
+ overflow: hidden;
2430
+ `;
2431
+ // 비디오 요소 렌더링
2432
+ this.renderVideoElementDirect(videoContainer, videoAd, slot);
2433
+ // 컨테이너에 추가
2434
+ container.innerHTML = '';
2435
+ container.appendChild(videoContainer);
2436
+ // VIEWABLE 이벤트 추적
2437
+ const trackEventCallback = this.createEventTrackingCallback();
2438
+ setTimeout(() => {
2439
+ trackEventCallback(videoAd._id, slot.id, AdEventType.VIEWABLE);
2440
+ }, 100);
2441
+ if (this.debug) {
2442
+ console.log(`🎬 Single video rendered for VIDEO slot: ${slot.id} (${videoAd._id})`);
2443
+ }
2444
+ }
2445
+ else {
2446
+ if (this.debug) {
2447
+ console.warn(`⚠️ No video advertisements available for slot: ${slot.id}`);
2448
+ }
2449
+ }
2450
+ }
2451
+ /**
2452
+ * 비디오 요소 직접 렌더링
2453
+ */
2454
+ renderVideoElementDirect(container, advertisement, slot) {
2455
+ // 비디오 컨테이너를 relative로 설정
2456
+ container.style.position = 'relative';
2457
+ const video = document.createElement('video');
2458
+ // 비디오 기본 설정
2459
+ video.style.width = '100%';
2460
+ video.style.height = '100%';
2461
+ video.style.objectFit = 'contain';
2462
+ video.preload = 'metadata';
2463
+ // 슬롯 설정에서 비디오 옵션들 확인 (기본값 적용)
2464
+ const config = slot.config;
2465
+ // 디버깅: config 내용 확인
2466
+ if (this.debug) {
2467
+ console.log('🎬 Video config received:', config);
2468
+ }
2469
+ // 기본 설정: 모든 컨트롤 숨김 (사용자 요구사항 - config에 상관없이 기본값 적용)
2470
+ video.controls = false;
2471
+ // 기본 설정: 자동 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
2472
+ video.autoplay = config?.autoplay === false ? false : true;
2473
+ // 기본 설정: 음소거 true (기본값 강제 적용)
2474
+ video.muted = true;
2475
+ // 기본 설정: 반복 재생 true (config에 상관없이 기본값 적용, 단 사용자가 명시적으로 false 설정시 존중)
2476
+ video.loop = config?.loop === false ? false : true;
2477
+ // 디버깅: 최종 비디오 설정 확인
2478
+ if (this.debug) {
2479
+ console.log('🎬 Final video settings:', {
2480
+ autoplay: video.autoplay,
2481
+ muted: video.muted,
2482
+ loop: video.loop,
2483
+ controls: video.controls
2484
+ });
2485
+ }
2486
+ // playsinline 설정 (모바일에서 전체화면 방지)
2487
+ if (config?.playsinline !== false) {
2488
+ video.setAttribute('playsinline', '');
2489
+ }
2490
+ // 사용자가 명시적으로 controls=true를 설정한 경우에만 오버라이드 (첫 번째 비디오는 기본값 유지)
2491
+ if (config?.controls === true) {
2492
+ video.controls = true;
2493
+ if (this.debug) {
2494
+ console.log('🎬 User explicitly enabled controls, overriding default');
2495
+ }
2496
+ }
2497
+ // 특별한 컨트롤 설정 처리
2498
+ if (config?.hideControls) {
2499
+ // 완전히 모든 컨트롤 숨김 (pointer-events까지 차단)
2500
+ video.controls = false;
2501
+ video.style.cssText += `
2502
+ pointer-events: none;
2503
+ `;
2504
+ }
2505
+ else if (config?.customControls) {
2506
+ // controls가 true일 때만 특정 컨트롤 숨기기 (CSS로 처리)
2507
+ if (video.controls) {
2508
+ const customControlsStyle = document.createElement('style');
2509
+ customControlsStyle.textContent = `
2510
+ video::-webkit-media-controls-play-button {
2511
+ display: ${config.customControls.hidePlayButton ? 'none' : 'block'} !important;
2512
+ }
2513
+ video::-webkit-media-controls-timeline {
2514
+ display: ${config.customControls.hideProgressBar ? 'none' : 'block'} !important;
2515
+ }
2516
+ video::-webkit-media-controls-current-time-display {
2517
+ display: ${config.customControls.hideCurrentTime ? 'none' : 'block'} !important;
2518
+ }
2519
+ video::-webkit-media-controls-time-remaining-display {
2520
+ display: ${config.customControls.hideRemainingTime ? 'none' : 'block'} !important;
2521
+ }
2522
+ video::-webkit-media-controls-volume-slider {
2523
+ display: ${config.customControls.hideVolumeSlider ? 'none' : 'block'} !important;
2524
+ }
2525
+ video::-webkit-media-controls-mute-button {
2526
+ display: ${config.customControls.hideMuteButton ? 'none' : 'block'} !important;
2527
+ }
2528
+ video::-webkit-media-controls-fullscreen-button {
2529
+ display: ${config.customControls.hideFullscreenButton ? 'none' : 'block'} !important;
2530
+ }
2531
+ `;
2532
+ document.head.appendChild(customControlsStyle);
2533
+ }
2534
+ }
2535
+ else if (video.controls) {
2536
+ // controls=true이지만 특별한 설정이 없을 때, 기본 스타일 적용 (시간만 표시)
2537
+ const defaultControlsStyle = document.createElement('style');
2538
+ defaultControlsStyle.id = 'adstage-video-default-controls';
2539
+ defaultControlsStyle.textContent = `
2540
+ .adstage-video-container video::-webkit-media-controls-mute-button {
2541
+ display: none !important;
2542
+ }
2543
+ .adstage-video-container video::-webkit-media-controls-fullscreen-button {
2544
+ display: none !important;
2545
+ }
2546
+ .adstage-video-container video::-webkit-media-controls-toggle-closed-captions-button {
2547
+ display: none !important;
2548
+ }
2549
+ .adstage-video-container video::-webkit-media-controls-volume-slider {
2550
+ display: none !important;
2551
+ }
2552
+ .adstage-video-container video::-webkit-media-controls-overflow-button {
2553
+ display: none !important;
2554
+ }
2555
+ .adstage-video-container video::-webkit-media-controls-picture-in-picture-button {
2556
+ display: none !important;
2557
+ }
2558
+ video::-webkit-media-controls-mute-button {
2559
+ display: none !important;
2560
+ }
2561
+ video::-webkit-media-controls-fullscreen-button {
2562
+ display: none !important;
2563
+ }
2564
+ video::-webkit-media-controls-toggle-closed-captions-button {
2565
+ display: none !important;
2566
+ }
2567
+ video::-webkit-media-controls-volume-slider {
2568
+ display: none !important;
2569
+ }
2570
+ video::-webkit-media-controls-overflow-button {
2571
+ display: none !important;
2572
+ }
2573
+ video::-webkit-media-controls-picture-in-picture-button {
2574
+ display: none !important;
2575
+ }
2576
+ `;
2577
+ // 스타일이 이미 존재하지 않으면 추가
2578
+ if (!document.getElementById('adstage-video-default-controls')) {
2579
+ document.head.appendChild(defaultControlsStyle);
2580
+ }
2581
+ }
2582
+ // 기본값이 controls=false이므로 아무것도 표시하지 않음
2583
+ if (this.debug) {
2584
+ console.log('🎬 Video controls setting:', video.controls ? 'enabled' : 'disabled (default)');
2585
+ }
2586
+ // 커스텀 음소거 토글 버튼 생성 (기본적으로 항상 표시)
2587
+ const muteButton = document.createElement('button');
2588
+ muteButton.className = 'adstage-video-mute-button';
2589
+ muteButton.style.cssText = `
2590
+ position: absolute;
2591
+ top: 12px;
2592
+ left: 12px;
2593
+ width: 40px;
2594
+ height: 40px;
2595
+ border: none;
2596
+ border-radius: 50%;
2597
+ background: rgba(0, 0, 0, 0.7);
2598
+ color: white;
2599
+ font-size: 16px;
2600
+ cursor: pointer;
2601
+ display: flex;
2602
+ align-items: center;
2603
+ justify-content: center;
2604
+ z-index: 10;
2605
+ transition: all 0.3s ease;
2606
+ backdrop-filter: blur(4px);
2607
+ `;
2608
+ // hideControls가 true면 음소거 버튼도 숨김
2609
+ if (config?.hideControls) {
2610
+ muteButton.style.display = 'none';
2611
+ }
2612
+ // 음소거 상태에 따른 아이콘 업데이트
2613
+ const updateMuteButtonIcon = () => {
2614
+ const mutedIcon = `
2615
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
2616
+ <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"/>
2617
+ </svg>
2618
+ `;
2619
+ const unmutedIcon = `
2620
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="white">
2621
+ <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"/>
2622
+ </svg>
2623
+ `;
2624
+ muteButton.innerHTML = video.muted ? mutedIcon : unmutedIcon;
2625
+ muteButton.title = video.muted ? 'Click to unmute' : 'Click to mute';
2626
+ };
2627
+ // 초기 아이콘 설정
2628
+ updateMuteButtonIcon();
2629
+ // 음소거 버튼 클릭 이벤트
2630
+ muteButton.addEventListener('click', (e) => {
2631
+ e.stopPropagation(); // 비디오 클릭 이벤트 방지
2632
+ video.muted = !video.muted;
2633
+ updateMuteButtonIcon();
2634
+ if (this.debug) {
2635
+ console.log(`🔊 Video mute toggled: ${video.muted ? 'muted' : 'unmuted'}`);
2636
+ }
2637
+ });
2638
+ // 마우스 호버 효과
2639
+ muteButton.addEventListener('mouseenter', () => {
2640
+ muteButton.style.background = 'rgba(0, 0, 0, 0.9)';
2641
+ muteButton.style.transform = 'scale(1.1)';
2642
+ });
2643
+ muteButton.addEventListener('mouseleave', () => {
2644
+ muteButton.style.background = 'rgba(0, 0, 0, 0.7)';
2645
+ muteButton.style.transform = 'scale(1)';
2646
+ });
2647
+ // 비디오 URL 설정
2648
+ if (advertisement.videoUrl) {
2649
+ video.src = advertisement.videoUrl;
2650
+ // 자동 재생을 위한 다단계 시도
2651
+ const attemptAutoplay = () => {
2652
+ if (video.autoplay && video.muted && video.paused) {
2653
+ video.play().catch(error => {
2654
+ if (this.debug) {
2655
+ console.warn('🎬 Auto-play was prevented:', error);
2656
+ console.warn('🎬 Trying muted autoplay fallback...');
2657
+ }
2658
+ // 음소거 상태에서 다시 시도
2659
+ video.muted = true;
2660
+ video.play().catch(fallbackError => {
2661
+ if (this.debug) {
2662
+ console.error('🎬 Autoplay completely failed:', fallbackError);
2663
+ }
2664
+ });
2665
+ });
2666
+ }
2667
+ };
2668
+ // 다양한 이벤트에서 자동 재생 시도
2669
+ video.addEventListener('loadedmetadata', () => {
2670
+ if (this.debug) {
2671
+ console.log('🎬 Video metadata loaded, attempting autoplay...');
2672
+ }
2673
+ attemptAutoplay();
2674
+ });
2675
+ video.addEventListener('canplay', () => {
2676
+ if (this.debug) {
2677
+ console.log('🎬 Video can play, attempting autoplay...');
2678
+ }
2679
+ attemptAutoplay();
2680
+ });
2681
+ video.addEventListener('loadeddata', () => {
2682
+ if (this.debug) {
2683
+ console.log('🎬 Video data loaded, attempting autoplay...');
2684
+ }
2685
+ attemptAutoplay();
2686
+ });
2687
+ // 비디오 로드 시작 즉시 한 번 시도
2688
+ setTimeout(() => {
2689
+ if (this.debug) {
2690
+ console.log('🎬 Initial autoplay attempt after timeout...');
2691
+ }
2692
+ attemptAutoplay();
2693
+ }, 100);
2694
+ }
2695
+ else if (advertisement.imageUrl) {
2696
+ // 비디오 URL이 없으면 이미지를 대체 표시
2697
+ const img = document.createElement('img');
2698
+ img.src = advertisement.imageUrl;
2699
+ img.style.width = '100%';
2700
+ img.style.height = '100%';
2701
+ img.style.objectFit = 'contain';
2702
+ img.alt = advertisement.title || 'Video thumbnail';
2703
+ container.appendChild(img);
2704
+ return;
2705
+ }
2706
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2707
+ AdClickHandler.addClickEventForRenderer(video, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Video');
2708
+ // 비디오 로드 에러 처리
2709
+ video.addEventListener('error', (e) => {
2710
+ console.error('Video load failed:', e);
2711
+ // 대체 이미지 표시
2712
+ if (advertisement.imageUrl) {
2713
+ const img = document.createElement('img');
2714
+ img.src = advertisement.imageUrl;
2715
+ img.style.width = '100%';
2716
+ img.style.height = '100%';
2717
+ img.style.objectFit = 'contain';
2718
+ img.alt = advertisement.title || 'Video thumbnail';
2719
+ // 클릭 이벤트 추가 (공통 컴포넌트 사용)
2720
+ AdClickHandler.addClickEventForRenderer(img, advertisement, slot, () => this.createEventTrackingCallback(), this.debug, 'Video fallback');
2721
+ container.innerHTML = '';
2722
+ container.appendChild(img);
2723
+ }
2724
+ });
2725
+ // 비디오와 음소거 버튼을 컨테이너에 추가
2726
+ container.appendChild(video);
2727
+ container.appendChild(muteButton);
2728
+ if (this.debug) {
2729
+ console.log(`🎬 Video element created for ad: ${advertisement._id} (autoplay: ${video.autoplay}, muted: ${video.muted}, loop: ${video.loop})`);
2730
+ }
2731
+ }
2732
+ }
2733
+
2734
+ /**
2735
+ * 네이티브 광고 전용 렌더러
2736
+ */
2737
+ class NativeAdRenderer extends BaseAdRenderer {
2738
+ constructor(debug = false, advertisementEventTracker) {
2739
+ super(AdType.NATIVE, debug, advertisementEventTracker);
2740
+ }
2741
+ /**
2742
+ * 네이티브 광고 기본 높이
2743
+ */
2744
+ getDefaultHeight() {
2745
+ return '200px';
2746
+ }
2747
+ /**
2748
+ * 단일 네이티브 광고 렌더링
2749
+ */
2750
+ async renderAdElement(slot, advertisement) {
2751
+ const container = document.getElementById(slot.containerId);
2752
+ if (!container)
2753
+ return;
2754
+ const adElement = document.createElement('div');
2755
+ adElement.className = 'adstage-ad adstage-native-ad';
2756
+ adElement.style.cssText = `
2757
+ width: 100%;
2758
+ height: 100%;
2759
+ display: flex;
2760
+ flex-direction: column;
2761
+ padding: 16px;
2762
+ background: #fff;
2763
+ border: 1px solid #e5e7eb;
2764
+ border-radius: 8px;
2765
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
2766
+ `;
2767
+ // 제목
2768
+ if (advertisement.title) {
2769
+ const title = document.createElement('h3');
2770
+ title.textContent = advertisement.title;
2771
+ title.style.cssText = `
2772
+ margin: 0 0 8px 0;
2773
+ font-size: 16px;
2774
+ font-weight: 600;
2775
+ color: #111827;
2776
+ line-height: 1.3;
2777
+ `;
2778
+ adElement.appendChild(title);
2779
+ }
2780
+ // 설명
2781
+ if (advertisement.textContent) {
2782
+ const description = document.createElement('p');
2783
+ description.textContent = advertisement.textContent;
2784
+ description.style.cssText = `
2785
+ margin: 0 0 12px 0;
2786
+ font-size: 14px;
2787
+ color: #6b7280;
2788
+ line-height: 1.4;
2789
+ flex: 1;
2790
+ `;
2791
+ adElement.appendChild(description);
2792
+ }
2793
+ // 이미지 (있는 경우)
2794
+ if (advertisement.imageUrl) {
2795
+ const imageContainer = document.createElement('div');
2796
+ imageContainer.style.cssText = `
2797
+ width: 100%;
2798
+ height: 120px;
2799
+ margin-bottom: 12px;
2800
+ border-radius: 6px;
2801
+ overflow: hidden;
2802
+ background: #f3f4f6;
2803
+ `;
2804
+ const img = document.createElement('img');
2805
+ img.src = advertisement.imageUrl;
2806
+ img.alt = advertisement.title || 'Native Advertisement';
2807
+ img.style.cssText = `
2808
+ width: 100%;
2809
+ height: 100%;
2810
+ object-fit: cover;
2811
+ `;
2812
+ imageContainer.appendChild(img);
2813
+ adElement.appendChild(imageContainer);
2814
+ }
2815
+ // CTA 버튼 (링크가 있는 경우)
2816
+ if (advertisement.linkUrl) {
2817
+ const ctaButton = document.createElement('button');
2818
+ ctaButton.textContent = 'Learn More';
2819
+ ctaButton.style.cssText = `
2820
+ padding: 8px 16px;
2821
+ background: #3b82f6;
2822
+ color: white;
2823
+ border: none;
2824
+ border-radius: 6px;
2825
+ font-size: 14px;
2826
+ font-weight: 500;
2827
+ cursor: pointer;
2828
+ align-self: flex-start;
2829
+ transition: background-color 0.2s;
2830
+ `;
2831
+ ctaButton.addEventListener('click', () => {
2832
+ if (this.advertisementEventTracker) {
2833
+ console.log(`Native click tracked for ad: ${advertisement._id}`);
2834
+ }
2835
+ window.open(advertisement.linkUrl, '_blank');
2836
+ });
2837
+ ctaButton.addEventListener('mouseenter', () => {
2838
+ ctaButton.style.backgroundColor = '#2563eb';
2839
+ });
2840
+ ctaButton.addEventListener('mouseleave', () => {
2841
+ ctaButton.style.backgroundColor = '#3b82f6';
2842
+ });
2843
+ adElement.appendChild(ctaButton);
2844
+ }
2845
+ container.innerHTML = '';
2846
+ container.appendChild(adElement);
2847
+ if (this.debug) {
2848
+ console.log(`🏠 Single native ad rendered: ${advertisement._id}`);
2849
+ }
2850
+ }
2851
+ /**
2852
+ * 다중 네이티브 광고 렌더링 - 현재는 단일 렌더링만 지원
2853
+ */
2854
+ async renderMultipleAds(slot, advertisements) {
2855
+ if (advertisements.length > 0) {
2856
+ await this.renderAdElement(slot, advertisements[0]);
2857
+ if (this.debug) {
2858
+ console.log(`🏠 Native ad rendered (first of ${advertisements.length}): ${slot.id}`);
2859
+ }
2860
+ }
2861
+ else {
2862
+ if (this.debug) {
2863
+ console.warn(`⚠️ No native advertisements available for slot: ${slot.id}`);
2864
+ }
2865
+ }
2866
+ }
2867
+ }
2868
+
2869
+ /**
2870
+ * 전면광고 전용 렌더러
2871
+ */
2872
+ class InterstitialAdRenderer extends BaseAdRenderer {
2873
+ constructor(debug = false, advertisementEventTracker) {
2874
+ super(AdType.INTERSTITIAL, debug, advertisementEventTracker);
2875
+ }
2876
+ /**
2877
+ * 전면광고 기본 높이 (크게 설정)
2878
+ */
2879
+ getDefaultHeight() {
2880
+ return '400px';
2881
+ }
2882
+ /**
2883
+ * 단일 전면광고 렌더링
2884
+ */
2885
+ async renderAdElement(slot, advertisement) {
2886
+ const container = document.getElementById(slot.containerId);
2887
+ if (!container)
2888
+ return;
2889
+ // 전면광고 오버레이 생성
2890
+ const overlay = document.createElement('div');
2891
+ overlay.className = 'adstage-interstitial-overlay';
2892
+ overlay.style.cssText = `
2893
+ position: fixed;
2894
+ top: 0;
2895
+ left: 0;
2896
+ width: 100vw;
2897
+ height: 100vh;
2898
+ background: rgba(0, 0, 0, 0.8);
2899
+ display: flex;
2900
+ align-items: center;
2901
+ justify-content: center;
2902
+ z-index: 10000;
2903
+ animation: fadeIn 0.3s ease-out;
2904
+ `;
2905
+ // 전면광고 콘텐츠
2906
+ const adContent = document.createElement('div');
2907
+ adContent.className = 'adstage-interstitial-content';
2908
+ adContent.style.cssText = `
2909
+ position: relative;
2910
+ max-width: 90vw;
2911
+ max-height: 90vh;
2912
+ background: white;
2913
+ border-radius: 12px;
2914
+ padding: 24px;
2915
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
2916
+ animation: slideUp 0.3s ease-out;
2917
+ overflow: auto;
2918
+ `;
2919
+ // 닫기 버튼
2920
+ const closeButton = document.createElement('button');
2921
+ closeButton.innerHTML = '×';
2922
+ closeButton.style.cssText = `
2923
+ position: absolute;
2924
+ top: 12px;
2925
+ right: 12px;
2926
+ width: 32px;
2927
+ height: 32px;
2928
+ border: none;
2929
+ background: #f3f4f6;
2930
+ color: #6b7280;
2931
+ font-size: 20px;
2932
+ font-weight: bold;
2933
+ border-radius: 50%;
2934
+ cursor: pointer;
2935
+ display: flex;
2936
+ align-items: center;
2937
+ justify-content: center;
2938
+ transition: all 0.2s;
2939
+ `;
2940
+ closeButton.addEventListener('click', () => {
2941
+ this.closeInterstitial(overlay);
2942
+ });
2943
+ closeButton.addEventListener('mouseenter', () => {
2944
+ closeButton.style.backgroundColor = '#e5e7eb';
2945
+ closeButton.style.color = '#374151';
2946
+ });
2947
+ closeButton.addEventListener('mouseleave', () => {
2948
+ closeButton.style.backgroundColor = '#f3f4f6';
2949
+ closeButton.style.color = '#6b7280';
2950
+ });
2951
+ // 이미지 (있는 경우)
2952
+ if (advertisement.imageUrl) {
2953
+ const img = document.createElement('img');
2954
+ img.src = advertisement.imageUrl;
2955
+ img.alt = advertisement.title || 'Interstitial Advertisement';
2956
+ img.style.cssText = `
2957
+ width: 100%;
2958
+ max-height: 300px;
2959
+ object-fit: cover;
2960
+ border-radius: 8px;
2961
+ margin-bottom: 16px;
2962
+ `;
2963
+ adContent.appendChild(img);
2964
+ }
2965
+ // 제목
2966
+ if (advertisement.title) {
2967
+ const title = document.createElement('h2');
2968
+ title.textContent = advertisement.title;
2969
+ title.style.cssText = `
2970
+ margin: 0 0 12px 0;
2971
+ font-size: 24px;
2972
+ font-weight: 700;
2973
+ color: #111827;
2974
+ line-height: 1.3;
2975
+ `;
2976
+ adContent.appendChild(title);
2977
+ }
2978
+ // 설명
2979
+ if (advertisement.textContent) {
2980
+ const description = document.createElement('p');
2981
+ description.textContent = advertisement.textContent;
2982
+ description.style.cssText = `
2983
+ margin: 0 0 20px 0;
2984
+ font-size: 16px;
2985
+ color: #6b7280;
2986
+ line-height: 1.5;
2987
+ `;
2988
+ adContent.appendChild(description);
2989
+ }
2990
+ // CTA 버튼 (링크가 있는 경우)
2991
+ if (advertisement.linkUrl) {
2992
+ const ctaButton = document.createElement('button');
2993
+ ctaButton.textContent = 'Get Started';
2994
+ ctaButton.style.cssText = `
2995
+ width: 100%;
2996
+ padding: 12px 24px;
2997
+ background: #3b82f6;
2998
+ color: white;
2999
+ border: none;
3000
+ border-radius: 8px;
3001
+ font-size: 16px;
3002
+ font-weight: 600;
3003
+ cursor: pointer;
3004
+ transition: background-color 0.2s;
3005
+ `;
3006
+ ctaButton.addEventListener('click', () => {
3007
+ if (this.advertisementEventTracker) {
3008
+ console.log(`Interstitial click tracked for ad: ${advertisement._id}`);
3009
+ }
3010
+ window.open(advertisement.linkUrl, '_blank');
3011
+ this.closeInterstitial(overlay);
3012
+ });
3013
+ ctaButton.addEventListener('mouseenter', () => {
3014
+ ctaButton.style.backgroundColor = '#2563eb';
3015
+ });
3016
+ ctaButton.addEventListener('mouseleave', () => {
3017
+ ctaButton.style.backgroundColor = '#3b82f6';
3018
+ });
3019
+ adContent.appendChild(ctaButton);
3020
+ }
3021
+ // ESC 키로 닫기
3022
+ const handleEscape = (event) => {
3023
+ if (event.key === 'Escape') {
3024
+ this.closeInterstitial(overlay);
3025
+ document.removeEventListener('keydown', handleEscape);
3026
+ }
3027
+ };
3028
+ document.addEventListener('keydown', handleEscape);
3029
+ // 오버레이 클릭으로 닫기
3030
+ overlay.addEventListener('click', (event) => {
3031
+ if (event.target === overlay) {
3032
+ this.closeInterstitial(overlay);
3033
+ }
3034
+ });
3035
+ // CSS 애니메이션 추가
3036
+ if (!document.getElementById('adstage-interstitial-styles')) {
3037
+ const styles = document.createElement('style');
3038
+ styles.id = 'adstage-interstitial-styles';
3039
+ styles.textContent = `
3040
+ @keyframes fadeIn {
3041
+ from { opacity: 0; }
3042
+ to { opacity: 1; }
3043
+ }
3044
+ @keyframes slideUp {
3045
+ from {
3046
+ opacity: 0;
3047
+ transform: translateY(20px);
3048
+ }
3049
+ to {
3050
+ opacity: 1;
3051
+ transform: translateY(0);
3052
+ }
3053
+ }
3054
+ `;
3055
+ document.head.appendChild(styles);
3056
+ }
3057
+ adContent.appendChild(closeButton);
3058
+ overlay.appendChild(adContent);
3059
+ document.body.appendChild(overlay);
3060
+ if (this.debug) {
3061
+ console.log(`🖼️ Interstitial ad rendered: ${advertisement._id}`);
3062
+ }
3063
+ }
3064
+ /**
3065
+ * 다중 전면광고 렌더링 - 현재는 단일 렌더링만 지원
3066
+ */
3067
+ async renderMultipleAds(slot, advertisements) {
3068
+ if (advertisements.length > 0) {
3069
+ await this.renderAdElement(slot, advertisements[0]);
3070
+ if (this.debug) {
3071
+ console.log(`🖼️ Interstitial ad rendered (first of ${advertisements.length}): ${slot.id}`);
3072
+ }
3073
+ }
3074
+ else {
3075
+ if (this.debug) {
3076
+ console.warn(`⚠️ No interstitial advertisements available for slot: ${slot.id}`);
3077
+ }
3078
+ }
3079
+ }
3080
+ /**
3081
+ * 전면광고 닫기
3082
+ */
3083
+ closeInterstitial(overlay) {
3084
+ overlay.style.animation = 'fadeOut 0.3s ease-out forwards';
3085
+ // CSS 애니메이션이 없으면 즉시 제거
3086
+ setTimeout(() => {
3087
+ if (overlay.parentNode) {
3088
+ overlay.parentNode.removeChild(overlay);
3089
+ }
3090
+ }, 300);
3091
+ if (this.debug) {
3092
+ console.log('🖼️ Interstitial ad closed');
3093
+ }
3094
+ }
3095
+ }
3096
+
3097
+ /**
3098
+ * AdRenderer - 광고 렌더링 팩토리 클래스
3099
+ * 광고 타입별로 적절한 렌더러를 생성하고 관리
3100
+ */
3101
+ class AdRenderer {
3102
+ constructor(debug = false, advertisementEventTracker) {
3103
+ this.renderers = new Map();
3104
+ this.debug = debug;
3105
+ this.advertisementEventTracker = advertisementEventTracker || null;
3106
+ // 각 광고 타입별 렌더러 초기화
3107
+ this.initializeRenderers();
3108
+ }
3109
+ /**
3110
+ * 광고 타입별 렌더러 초기화
3111
+ */
3112
+ initializeRenderers() {
3113
+ this.renderers.set(AdType.BANNER, new BannerAdRenderer(this.debug, this.advertisementEventTracker));
3114
+ this.renderers.set(AdType.TEXT, new TextAdRenderer(this.debug, this.advertisementEventTracker));
3115
+ this.renderers.set(AdType.VIDEO, new VideoAdRenderer(this.debug, this.advertisementEventTracker));
3116
+ this.renderers.set(AdType.NATIVE, new NativeAdRenderer(this.debug, this.advertisementEventTracker));
3117
+ this.renderers.set(AdType.INTERSTITIAL, new InterstitialAdRenderer(this.debug, this.advertisementEventTracker));
3118
+ if (this.debug) {
3119
+ console.log(`🏭 AdRenderer factory initialized with ${this.renderers.size} renderers`);
3120
+ }
3121
+ }
3122
+ /**
3123
+ * 광고 요소를 동기적으로 생성해서 반환 (크기 측정 등을 위한 helper)
3124
+ */
3125
+ createAdElement(slot, advertisement) {
3126
+ const renderer = this.getRenderer(slot.adType);
3127
+ // 기본 광고 요소 생성
3128
+ const adElement = document.createElement('div');
3129
+ adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
3130
+ adElement.setAttribute('data-adstage-ad-id', advertisement._id);
3131
+ adElement.setAttribute('data-adstage-slot-id', slot.id);
3132
+ // 광고 타입별 기본 컨테이너 설정
3133
+ const { width, height } = renderer.calculateAdSize(adElement, slot.config || {}, advertisement);
3134
+ adElement.style.width = width;
3135
+ adElement.style.height = height;
3136
+ adElement.style.display = 'block';
3137
+ // 간단한 내용 설정 (크기 측정용)
3138
+ switch (slot.adType) {
3139
+ case AdType.BANNER:
3140
+ if (advertisement.imageUrl) {
3141
+ const img = document.createElement('img');
3142
+ img.src = advertisement.imageUrl;
3143
+ img.style.width = '100%';
3144
+ img.style.height = '100%';
3145
+ img.style.objectFit = 'cover';
3146
+ adElement.appendChild(img);
3147
+ }
3148
+ break;
3149
+ case AdType.VIDEO:
3150
+ if (advertisement.videoUrl) {
3151
+ const video = document.createElement('video');
3152
+ video.src = advertisement.videoUrl;
3153
+ video.style.width = '100%';
3154
+ video.style.height = '100%';
3155
+ adElement.appendChild(video);
3156
+ }
3157
+ break;
3158
+ case AdType.TEXT:
3159
+ if (advertisement.textContent) {
3160
+ const textDiv = document.createElement('div');
3161
+ textDiv.textContent = advertisement.textContent || '';
3162
+ textDiv.style.padding = '8px';
3163
+ adElement.appendChild(textDiv);
3164
+ }
3165
+ break;
3166
+ default:
3167
+ // 기본 placeholder
3168
+ adElement.style.border = '1px dashed #ccc';
3169
+ adElement.style.backgroundColor = '#f9f9f9';
3170
+ adElement.textContent = `${slot.adType} Ad`;
3171
+ }
3172
+ return adElement;
3173
+ }
3174
+ /**
3175
+ * 광고 타입에 따른 렌더러 획득
3176
+ */
3177
+ getRenderer(adType) {
3178
+ const renderer = this.renderers.get(adType);
3179
+ if (!renderer) {
3180
+ throw new Error(`No renderer found for ad type: ${adType}`);
3181
+ }
3182
+ return renderer;
3183
+ }
3184
+ /**
3185
+ * Placeholder(슬롯 컨테이너) 생성
3186
+ */
3187
+ createPlaceholder(container, slotId, type, options, config) {
3188
+ const renderer = this.getRenderer(type);
3189
+ renderer.createPlaceholder(container, slotId, options, config);
3190
+ }
3191
+ /**
3192
+ * 배너 광고를 위한 컨테이너 최적화 (배너 전용)
3193
+ */
3194
+ async optimizeContainerForBannerAds(slot, advertisements) {
3195
+ if (slot.adType !== AdType.BANNER) {
3196
+ if (this.debug) {
3197
+ console.warn(`⚠️ Container optimization is only supported for BANNER ads, got: ${slot.adType}`);
3198
+ }
3199
+ return;
3200
+ }
3201
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3202
+ await bannerRenderer.optimizeContainerForBannerAds(slot, advertisements);
3203
+ }
3204
+ /**
3205
+ * 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
3206
+ */
3207
+ async renderAdSlider(slot, advertisements) {
3208
+ const renderer = this.getRenderer(slot.adType);
3209
+ await renderer.renderMultipleAds(slot, advertisements);
3210
+ }
3211
+ /**
3212
+ * 광고 렌더링 (단일 광고용)
3213
+ */
3214
+ async renderAd(slot) {
3215
+ if (!slot.advertisement) {
3216
+ throw new Error('No advertisement to render');
3217
+ }
3218
+ const renderer = this.getRenderer(slot.adType);
3219
+ await renderer.renderAdElement(slot, slot.advertisement);
3220
+ slot.isLoaded = true;
3221
+ }
3222
+ /**
3223
+ * 광고 요소 렌더링 (기본 구현) - 호환성을 위해 유지
3224
+ */
3225
+ async renderAdElement(slot, ad) {
3226
+ const renderer = this.getRenderer(slot.adType);
3227
+ await renderer.renderAdElement(slot, ad);
3228
+ }
3229
+ /**
3230
+ * Fallback 광고 렌더링 - 컨테이너 접기/생성
3231
+ */
3232
+ renderFallback(slot) {
3233
+ const renderer = this.getRenderer(slot.adType);
3234
+ renderer.renderFallback(slot);
3235
+ }
3236
+ /**
3237
+ * 광고 타입별 기본 높이 반환
3238
+ */
3239
+ getDefaultHeightForAdType(type) {
3240
+ const renderer = this.getRenderer(type);
3241
+ return renderer.getDefaultHeight();
3242
+ }
3243
+ /**
3244
+ * 컨테이너와 광고 타입에 따른 스마트한 크기 계산
3245
+ */
3246
+ calculateAdSize(container, type, options, config) {
3247
+ const renderer = this.getRenderer(type);
3248
+ return renderer.calculateAdSize(container, options, config);
3249
+ }
3250
+ /**
3251
+ * 이미지 로드 및 실제 크기 획득 (배너 전용)
3252
+ */
3253
+ loadImageDimensions(imageUrl) {
3254
+ return new Promise((resolve, reject) => {
3255
+ const img = new Image();
3256
+ img.onload = () => {
3257
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
3258
+ };
3259
+ img.onerror = () => {
3260
+ reject(new Error(`Failed to load image: ${imageUrl}`));
3261
+ };
3262
+ img.src = imageUrl;
3263
+ });
3264
+ }
3265
+ /**
3266
+ * 배너 이미지 최적화 렌더링 (배너 전용)
3267
+ */
3268
+ async renderOptimizedBannerImage(container, advertisement, slot) {
3269
+ if (slot.adType !== AdType.BANNER) {
3270
+ throw new Error('renderOptimizedBannerImage is only supported for BANNER ads');
3271
+ }
3272
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3273
+ return await bannerRenderer.renderOptimizedBannerImage(container, advertisement, slot);
3274
+ }
3275
+ /**
3276
+ * 여러 광고의 최적 컨테이너 크기 계산 (배너 전용)
3277
+ */
3278
+ async calculateOptimalContainerSize(advertisements, containerWidth, adType) {
3279
+ if (adType !== AdType.BANNER) {
3280
+ const renderer = this.getRenderer(adType);
3281
+ return {
3282
+ width: '100%',
3283
+ height: renderer.getDefaultHeight(),
3284
+ aspectRatio: 16 / 9
3285
+ };
3286
+ }
3287
+ const bannerRenderer = this.getRenderer(AdType.BANNER);
3288
+ return await bannerRenderer.calculateOptimalContainerSize(advertisements, containerWidth);
3289
+ }
3290
+ /**
3291
+ * 디버그 로그 출력
3292
+ */
3293
+ log(message, ...args) {
3294
+ if (this.debug) {
3295
+ console.log(`[AdRenderer] ${message}`, ...args);
3296
+ }
3297
+ }
3298
+ }
3299
+
3300
+ /**
3301
+ * AdStage SDK - Ads 모듈
3302
+ * 광고 관리 및 렌더링 기능
3303
+ */
3304
+ class AdsModule {
3305
+ constructor() {
3306
+ this._isReady = false;
3307
+ this._config = null;
3308
+ this.slots = new Map();
3309
+ // Advertisement 이벤트 추적 관련
3310
+ this.advertisementEventTracker = null;
3311
+ // 렌더링 관련
3312
+ this.adRenderer = null;
3313
+ // DOM 변화 감지를 위한 MutationObserver
3314
+ this.mutationObserver = null;
3315
+ }
3316
+ /**
3317
+ * Ads 모듈 초기화 (동기)
3318
+ */
3319
+ init(config) {
3320
+ this._config = config;
3321
+ // AdvertisementEventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
3322
+ this.advertisementEventTracker = new AdvertisementEventTracker(endpoints.getBaseUrl(), config.apiKey, config.debug || false, this.slots);
3323
+ // AdRenderer 초기화
3324
+ this.adRenderer = new AdRenderer(config.debug || false, this.advertisementEventTracker);
3325
+ // DOM 변화 감지를 위한 MutationObserver 설정
3326
+ this.setupAutoCleanup();
3327
+ this._isReady = true;
3328
+ if (config.debug) {
3329
+ console.log('🎯 Ads module initialized (sync mode) with auto-cleanup');
3330
+ }
3331
+ }
3332
+ /**
3333
+ * 모듈 준비 상태 확인
3334
+ */
3335
+ isReady() {
3336
+ return this._isReady;
3337
+ }
3338
+ /**
3339
+ * 모듈 설정 반환
3340
+ */
3341
+ getConfig() {
3342
+ return this._config;
3343
+ }
3344
+ /**
3345
+ * 배너 광고 생성 (동기)
3346
+ */
3347
+ banner(containerId, options) {
3348
+ this.ensureReady();
3349
+ const adstageOptions = {
3350
+ width: options?.width || '100%',
3351
+ height: options?.height || 'auto', // 🔧 동적 크기 조정 활용
3352
+ autoSlide: options?.autoSlide || false,
3353
+ slideInterval: options?.slideInterval || 5000,
3354
+ onClick: options?.onClick,
3355
+ // 특정 광고 ID
3356
+ adId: options?.adId,
3357
+ // 필터링 옵션들 전달
3358
+ language: options?.language,
3359
+ deviceType: options?.deviceType,
3360
+ country: options?.country
3361
+ };
3362
+ return this.createAd(containerId, AdType.BANNER, adstageOptions);
3363
+ }
3364
+ /**
3365
+ * 텍스트 광고 생성 (동기)
3366
+ */
3367
+ text(containerId, options) {
3368
+ this.ensureReady();
3369
+ const adstageOptions = {
3370
+ maxLines: options?.maxLines || 3,
3371
+ style: options?.style || 'default',
3372
+ onClick: options?.onClick,
3373
+ // 특정 광고 ID
3374
+ adId: options?.adId,
3375
+ // 필터링 옵션들 전달
3376
+ language: options?.language,
3377
+ deviceType: options?.deviceType,
3378
+ country: options?.country
3379
+ };
3380
+ return this.createAd(containerId, AdType.TEXT, adstageOptions);
3381
+ }
3382
+ /**
3383
+ * 비디오 광고 생성 (동기) - 단일 비디오만 지원
3384
+ */
3385
+ video(containerId, options) {
3386
+ this.ensureReady();
3387
+ const adstageOptions = {
3388
+ width: options?.width || 640,
3389
+ height: options?.height || 360,
3390
+ autoplay: options?.autoplay !== undefined ? options.autoplay : true, // 기본값 true (사용자 요구사항)
3391
+ muted: options?.muted !== undefined ? options.muted : true, // 기본값 true
3392
+ loop: options?.loop !== undefined ? options.loop : true, // 기본값 true (사용자 요구사항)
3393
+ playsinline: options?.playsinline !== false, // 기본값 true
3394
+ controls: options?.controls !== undefined ? options.controls : false, // 기본값 false (사용자 요구사항)
3395
+ hideControls: options?.hideControls || false,
3396
+ customControls: options?.customControls,
3397
+ autoSlide: false, // 비디오는 슬라이드 비활성화
3398
+ maxAds: 1, // 하나의 비디오만 가져오기
3399
+ onClick: options?.onClick,
3400
+ ...(options?.adId && { adId: options.adId }) // 특정 비디오 ID가 있으면 사용
3401
+ };
3402
+ return this.createAd(containerId, AdType.VIDEO, adstageOptions);
3403
+ }
3404
+ /**
3405
+ * 네이티브 광고 생성 (동기)
3406
+ */
3407
+ native(containerId, options) {
3408
+ this.ensureReady();
3409
+ return this.createAd(containerId, AdType.NATIVE, options || {});
3410
+ }
3411
+ /**
3412
+ * 전면 광고 생성 (동기)
3413
+ */
3414
+ interstitial(containerId, options) {
3415
+ this.ensureReady();
3416
+ return this.createAd(containerId, AdType.INTERSTITIAL, options || {});
3417
+ }
3418
+ /**
3419
+ * 광고 새로고침
3420
+ */
3421
+ refresh(slotId) {
3422
+ this.ensureReady();
3423
+ const slot = this.slots.get(slotId);
3424
+ if (!slot) {
3425
+ throw new Error(`Ad slot not found: ${slotId}`);
3426
+ }
3427
+ // 광고 새로고침 로직
3428
+ this.refreshAdSlot(slot);
3429
+ if (this._config?.debug) {
3430
+ console.log(`🔄 Ad slot refreshed: ${slotId}`);
3431
+ }
3432
+ }
3433
+ /**
3434
+ * 광고 제거
3435
+ */
3436
+ destroy(slotId) {
3437
+ this.ensureReady();
3438
+ const slot = this.slots.get(slotId);
3439
+ if (!slot) {
3440
+ throw new Error(`Ad slot not found: ${slotId}`);
3441
+ }
3442
+ // SimpleViewabilityTracker 정리
3443
+ if (slot.viewabilityTracker) {
3444
+ slot.viewabilityTracker.destroy();
3445
+ }
3446
+ // DOM에서 제거
3447
+ const container = document.getElementById(slot.containerId);
3448
+ if (container) {
3449
+ container.innerHTML = '';
3450
+ }
3451
+ // 슬롯 제거
3452
+ this.slots.delete(slotId);
3453
+ if (this._config?.debug) {
3454
+ console.log(`🗑️ Ad slot destroyed: ${slotId}`);
3455
+ }
3456
+ }
3457
+ /**
3458
+ * 모든 광고 슬롯 반환
3459
+ */
3460
+ getAllSlots() {
3461
+ this.ensureReady();
3462
+ return Array.from(this.slots.values());
3463
+ }
3464
+ /**
3465
+ * 특정 광고 슬롯 반환
3466
+ */
3467
+ getSlotById(slotId) {
3468
+ this.ensureReady();
3469
+ return this.slots.get(slotId) || null;
3470
+ }
3471
+ /**
3472
+ * 광고 생성 내부 메소드 (동기 + Lazy 로딩)
3473
+ */
3474
+ createAd(containerId, type, options) {
3475
+ if (!this._config?.apiKey) {
3476
+ throw new Error('API key not configured');
3477
+ }
3478
+ // 즉시 슬롯 ID 생성 (개발자에게 바로 반환)
3479
+ const slotId = `adstage-${type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3480
+ // 비동기로 광고 생성 처리 (디바운싱 및 재시도 로직 포함)
3481
+ this.createAdWithRetry(containerId, type, options, slotId, 0);
3482
+ return slotId;
3483
+ }
3484
+ async createAdWithRetry(containerId, type, options, slotId, attempt) {
3485
+ const maxAttempts = 5;
3486
+ const delays = [0, 50, 100, 200, 500]; // 점진적 지연
3487
+ let container = null;
3488
+ let containerIdString;
3489
+ // HTMLElement인지 string인지 구분
3490
+ if (typeof containerId === 'string') {
3491
+ // string인 경우 DOM에서 찾기
3492
+ containerIdString = containerId;
3493
+ container = document.getElementById(containerId);
3494
+ if (!container) {
3495
+ if (attempt < maxAttempts - 1) {
3496
+ if (this._config?.debug) {
3497
+ console.warn(`Container not found: ${containerId}. Retrying in ${delays[attempt + 1]}ms... (attempt ${attempt + 1}/${maxAttempts})`);
3498
+ }
3499
+ setTimeout(() => {
3500
+ this.createAdWithRetry(containerId, type, options, slotId, attempt + 1);
3501
+ }, delays[attempt + 1]);
3502
+ return;
3503
+ }
3504
+ else {
3505
+ console.error(`Container not found after ${maxAttempts} attempts: ${containerId}`);
3506
+ return;
3507
+ }
3508
+ }
3509
+ }
3510
+ else {
3511
+ // HTMLElement인 경우 직접 사용
3512
+ container = containerId;
3513
+ containerIdString = container.id || `auto-${slotId}`;
3514
+ // ID가 없으면 자동 생성
3515
+ if (!container.id) {
3516
+ container.id = containerIdString;
3517
+ }
3518
+ }
3519
+ // 컨테이너를 찾았으면 광고 생성
3520
+ try {
3521
+ this.createAdInternal(containerIdString, type, options, slotId, container);
3522
+ }
3523
+ catch (error) {
3524
+ console.error('광고 생성 중 오류 발생:', error);
3525
+ }
3526
+ }
3527
+ createAdInternal(containerId, type, options, slotId, container) {
3528
+ // 컨테이너에 슬롯 ID 속성 추가 (MutationObserver가 감지할 수 있도록)
3529
+ container.setAttribute('data-adstage-slot-id', slotId);
3530
+ // 즉시 placeholder 생성 (AdRenderer 위임)
3531
+ this.adRenderer?.createPlaceholder(container, slotId, type, options, this._config);
3532
+ // 광고 슬롯 정보 저장
3533
+ const slot = {
3534
+ id: slotId,
3535
+ containerId,
3536
+ adType: type,
3537
+ width: options.width || '100%',
3538
+ height: options.height || (type === AdType.TEXT ? 'auto' : 250), // 텍스트 광고는 콘텐츠 높이에 맞춤
3539
+ isLoaded: false,
3540
+ isVisible: false,
3541
+ refreshRate: 0,
3542
+ lazyLoad: false,
3543
+ targeting: {},
3544
+ advertisement: undefined, // 나중에 로드
3545
+ config: { type, ...options },
3546
+ load: async () => this.fetchAdData(type, options).then(ads => ads[0] || null),
3547
+ render: (ad) => this.adRenderer?.renderAdElement(slot, ad),
3548
+ refresh: async () => this.refreshAdSlot(slot),
3549
+ destroy: () => this.destroy(slotId)
3550
+ };
3551
+ // 슬롯 저장
3552
+ this.slots.set(slotId, slot);
3553
+ // 백그라운드에서 광고 로드
3554
+ this.loadAdContentInBackground(slot);
3555
+ // 이벤트 추적 준비
3556
+ if (this.advertisementEventTracker && this._config?.debug) {
3557
+ console.log(`📊 Advertisement event tracking enabled for slot: ${slotId}`);
3558
+ }
3559
+ }
3560
+ /**
3561
+ * 백그라운드에서 광고 콘텐츠 로드
3562
+ */
3563
+ async loadAdContentInBackground(slot) {
3564
+ try {
3565
+ // 광고 데이터 가져오기 - 여러 개 로드
3566
+ const adstageData = await this.fetchAdData(slot.adType, slot.config);
3567
+ if (!adstageData || adstageData.length === 0) {
3568
+ this.adRenderer?.renderFallback(slot);
3569
+ return;
3570
+ }
3571
+ // 🆕 동적 크기 조정: 배너 광고의 경우 이미지 크기 기반으로 컨테이너 최적화
3572
+ if (slot.adType === AdType.BANNER && adstageData.length > 0) {
3573
+ await this.adRenderer?.optimizeContainerForBannerAds(slot, adstageData);
3574
+ }
3575
+ // 광고가 여러 개이거나 autoSlide 옵션이 있으면 슬라이더로 렌더링
3576
+ if (adstageData.length > 1 || slot.config?.autoSlide) {
3577
+ await this.adRenderer?.renderAdSlider(slot, adstageData);
3578
+ // 🔧 슬라이더는 CarouselSliderManager에서 자체적으로 모든 광고의 VIEWABLE 이벤트를 처리
3579
+ if (this._config?.debug) {
3580
+ console.log(`🎠 Slider will handle VIEWABLE events for ${adstageData.length} ads automatically`);
3581
+ }
3582
+ }
3583
+ else {
3584
+ // 광고가 1개면 일반 렌더링
3585
+ slot.advertisement = adstageData[0];
3586
+ await this.adRenderer?.renderAdElement(slot, adstageData[0]);
3587
+ // ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
3588
+ this.startSimpleViewabilityTracking(slot, adstageData[0]);
3589
+ }
3590
+ slot.isLoaded = true;
3591
+ if (this._config?.debug) {
3592
+ console.log(`✅ Ad loaded for slot: ${slot.id} (${adstageData.length} ads)`);
3593
+ }
3594
+ }
3595
+ catch (error) {
3596
+ console.error(`❌ Failed to load ad for slot: ${slot.id}`, error);
3597
+ this.adRenderer?.renderFallback(slot);
3598
+ }
3599
+ }
3600
+ /**
3601
+ * 배너 광고를 위한 컨테이너 최적화
3602
+ */
3603
+ // optimizeContainerForBannerAds 제거: AdRenderer.optimizeContainerForBannerAds 사용
3604
+ /**
3605
+ * 단순한 노출 추적 시작 (재시도 로직 포함)
3606
+ */
3607
+ startSimpleViewabilityTracking(slot, ad) {
3608
+ const tryStartTracking = (retryCount = 0) => {
3609
+ const element = document.getElementById(slot.id);
3610
+ if (!element) {
3611
+ if (retryCount < 5) {
3612
+ // 최대 5번 재시도 (총 1.5초)
3613
+ setTimeout(() => tryStartTracking(retryCount + 1), 300);
3614
+ if (this._config?.debug) {
3615
+ console.log(`🔄 Retrying viewability tracking for slot: ${slot.id} (attempt ${retryCount + 1})`);
3616
+ }
3617
+ }
3618
+ else {
3619
+ console.warn(`❌ Failed to find element for viewability tracking: ${slot.id}`);
3620
+ }
3621
+ return;
3622
+ }
3623
+ // 단순한 노출 추적
3624
+ const tracker = new SimpleViewabilityTracker(element, async () => {
3625
+ await this.handleViewableEvent(ad, slot);
3626
+ });
3627
+ // 정리를 위해 저장
3628
+ slot.viewabilityTracker = tracker;
3629
+ if (this._config?.debug) {
3630
+ console.log(`🎯 Simple viewability tracking started for slot: ${slot.id} (element found)`);
3631
+ }
3632
+ };
3633
+ tryStartTracking();
3634
+ }
3635
+ /**
3636
+ * Viewable 이벤트 처리 (단순화됨)
3637
+ */
3638
+ async handleViewableEvent(ad, slot) {
3639
+ try {
3640
+ // VIEWABLE 이벤트 전송
3641
+ if (this.advertisementEventTracker) {
3642
+ await this.advertisementEventTracker.trackAdvertisementEvent(ad._id, slot.id, AdEventType.VIEWABLE);
3643
+ if (this._config?.debug) {
3644
+ console.log(`✅ Simple viewable impression tracked for ad ${ad._id}`);
3645
+ }
3646
+ }
3647
+ }
3648
+ catch (error) {
3649
+ console.error(`❌ Failed to track viewable impression:`, error);
3650
+ }
3651
+ }
3652
+ /**
3653
+ * 광고 데이터 가져오기
3654
+ */
3655
+ async fetchAdData(type, options) {
3656
+ if (!this._config?.apiKey) {
3657
+ throw new Error('API key not configured');
3658
+ }
3659
+ // 특정 광고 ID가 지정된 경우, 해당 광고만 요청
3660
+ if (options.adId) {
3661
+ const url = `${endpoints.advertisements.detail(options.adId)}`;
3662
+ const response = await fetch(url, {
3663
+ method: 'GET',
3664
+ headers: ApiHeaders.create(this._config.apiKey)
3665
+ });
3666
+ if (!response.ok) {
3667
+ if (response.status === 404) {
3668
+ console.warn(`🚫 Advertisement not found with ID: ${options.adId}`);
3669
+ return [];
3670
+ }
3671
+ throw new Error(`Failed to fetch ad data: ${response.status}`);
3672
+ }
3673
+ const advertisement = await response.json();
3674
+ if (this._config?.debug) {
3675
+ console.log(`📊 Fetched specific ad with ID: ${options.adId}`, advertisement);
3676
+ }
3677
+ return [advertisement];
3678
+ }
3679
+ // 일반적인 광고 목록 요청
3680
+ const params = new URLSearchParams();
3681
+ params.append('adType', type);
3682
+ // 백엔드 API에서 실제 지원하는 필터링 옵션들만 추가
3683
+ if (options.language) {
3684
+ params.append('language', options.language);
3685
+ }
3686
+ if (options.deviceType) {
3687
+ params.append('deviceType', options.deviceType);
3688
+ }
3689
+ if (options.country) {
3690
+ params.append('country', options.country);
3691
+ }
3692
+ const url = `${endpoints.advertisements.list()}?${params.toString()}`;
3693
+ const response = await fetch(url, {
3694
+ method: 'GET',
3695
+ headers: ApiHeaders.create(this._config.apiKey)
3696
+ });
3697
+ if (!response.ok) {
3698
+ throw new Error(`Failed to fetch ad data: ${response.status}`);
3699
+ }
3700
+ const result = await response.json();
3701
+ const advertisements = result.advertisements || [];
3702
+ if (this._config?.debug) {
3703
+ console.log(`📊 Fetched ${advertisements.length} ads for type: ${type}, filters:`, {
3704
+ language: options.language,
3705
+ deviceType: options.deviceType,
3706
+ country: options.country
3707
+ });
3708
+ }
3709
+ return advertisements;
3710
+ }
3711
+ /**
3712
+ * 광고 슬롯 새로고침
3713
+ */
3714
+ async refreshAdSlot(slot) {
3715
+ try {
3716
+ // 새로운 광고 데이터 가져오기 (config에서 타입과 옵션 정보 사용)
3717
+ const newAdData = await this.fetchAdData(slot.adType, slot.config || {});
3718
+ if (newAdData && newAdData.length > 0) {
3719
+ slot.advertisement = newAdData[0]; // 첫 번째 광고로 업데이트
3720
+ await this.adRenderer?.renderAd(slot);
3721
+ // 새로운 노출 추적
3722
+ if (this.advertisementEventTracker) {
3723
+ console.log('New advertisement viewable tracked for slot:', slot.id);
3724
+ }
3725
+ }
3726
+ }
3727
+ catch (error) {
3728
+ console.error(`Failed to refresh ad slot: ${slot.id}`, error);
3729
+ }
3730
+ }
3731
+ /**
3732
+ * 모듈 준비 상태 확인
3733
+ */
3734
+ ensureReady() {
3735
+ if (!this._isReady) {
3736
+ throw new Error('Ads module not initialized. Call AdStage.init() first.');
3737
+ }
3738
+ }
3739
+ /**
3740
+ * DOM 변화 감지를 통한 자동 정리 설정
3741
+ */
3742
+ setupAutoCleanup() {
3743
+ // 브라우저 환경에서만 실행
3744
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
3745
+ return;
3746
+ }
3747
+ // 기존 observer가 있으면 해제
3748
+ if (this.mutationObserver) {
3749
+ this.mutationObserver.disconnect();
3750
+ }
3751
+ // 새로운 MutationObserver 생성
3752
+ this.mutationObserver = new MutationObserver((mutations) => {
3753
+ mutations.forEach((mutation) => {
3754
+ // 제거된 노드들 확인
3755
+ mutation.removedNodes.forEach((node) => {
3756
+ if (node.nodeType === Node.ELEMENT_NODE) {
3757
+ this.handleRemovedElement(node);
3758
+ }
3759
+ });
3760
+ });
3761
+ });
3762
+ // document 전체를 관찰 (childList와 subtree 옵션 사용)
3763
+ this.mutationObserver.observe(document.body, {
3764
+ childList: true,
3765
+ subtree: true
3766
+ });
3767
+ if (this._config?.debug) {
3768
+ console.log('🔍 Auto-cleanup MutationObserver enabled');
3769
+ }
3770
+ }
3771
+ /**
3772
+ * 제거된 요소에서 광고 슬롯 정리
3773
+ */
3774
+ handleRemovedElement(element) {
3775
+ // 제거된 요소가 광고 컨테이너인지 확인
3776
+ const slotId = element.getAttribute('data-adstage-slot-id');
3777
+ if (slotId) {
3778
+ this.autoDestroy(slotId);
3779
+ return;
3780
+ }
3781
+ // 제거된 요소의 하위에 광고 컨테이너가 있는지 확인
3782
+ const adContainers = element.querySelectorAll('[data-adstage-slot-id]');
3783
+ adContainers.forEach((container) => {
3784
+ const containerSlotId = container.getAttribute('data-adstage-slot-id');
3785
+ if (containerSlotId) {
3786
+ this.autoDestroy(containerSlotId);
3787
+ }
3788
+ });
3789
+ }
3790
+ /**
3791
+ * 자동 정리 (로그 없이 조용히 정리)
3792
+ */
3793
+ autoDestroy(slotId) {
3794
+ const slot = this.slots.get(slotId);
3795
+ if (slot) {
3796
+ try {
3797
+ // 슬롯 정리 (로그 출력 최소화)
3798
+ this.slots.delete(slotId);
3799
+ if (this._config?.debug) {
3800
+ console.log(`🧹 Auto-cleanup: slot ${slotId} removed`);
3801
+ }
3802
+ }
3803
+ catch (error) {
3804
+ if (this._config?.debug) {
3805
+ console.warn(`Auto-cleanup failed for slot ${slotId}:`, error);
3806
+ }
3807
+ }
3808
+ }
3809
+ }
3810
+ /**
3811
+ * 모듈 종료 시 정리
3812
+ */
3813
+ destroyModule() {
3814
+ const debugMode = this._config?.debug;
3815
+ // MutationObserver 해제
3816
+ if (this.mutationObserver) {
3817
+ this.mutationObserver.disconnect();
3818
+ this.mutationObserver = null;
3819
+ }
3820
+ // 모든 슬롯 정리
3821
+ this.slots.clear();
3822
+ // 다른 리소스들 정리
3823
+ this.advertisementEventTracker = null;
3824
+ this.adRenderer = null;
3825
+ this._isReady = false;
3826
+ this._config = null;
3827
+ if (debugMode) {
3828
+ console.log('🗑️ Ads module destroyed');
3829
+ }
3830
+ }
3831
+ }
3832
+
3833
+ /**
3834
+ * AdStage SDK - Config 모듈
3835
+ * 설정 관리 및 API 키 검증
3836
+ */
3837
+ class ConfigModule {
3838
+ constructor() {
3839
+ this._isReady = false;
3840
+ this._config = null;
3841
+ this._organizationInfo = null;
3842
+ }
3843
+ /**
3844
+ * Config 모듈 초기화 (동기)
3845
+ */
3846
+ init(config) {
3847
+ // 설정만 저장 (서버 검증 없음)
3848
+ this._config = {
3849
+ timeout: 30000,
3850
+ debug: false,
3851
+ modules: ['ads', 'events', 'config'],
3852
+ ...config
3853
+ };
3854
+ // 사용자가 baseUrl을 제공한 경우 endpoints에 설정
3855
+ if (config.baseUrl) {
3856
+ endpoints.setBaseUrl(config.baseUrl);
3857
+ }
3858
+ this._isReady = true;
3859
+ if (config.debug) {
3860
+ console.log('✅ Config module initialized (sync mode)', {
3861
+ modules: this._config.modules,
3862
+ endpoint: endpoints.getBaseUrl(),
3863
+ mode: 'development'
3864
+ });
3865
+ }
3866
+ }
3867
+ /**
3868
+ * 모듈 준비 상태 확인
3869
+ */
3870
+ isReady() {
3871
+ return this._isReady;
3872
+ }
3873
+ /**
3874
+ * 현재 설정 반환
3875
+ */
3876
+ getConfig() {
3877
+ return this._config;
3878
+ }
3879
+ /**
3880
+ * 조직 정보 반환
3881
+ */
3882
+ getOrganizationInfo() {
3883
+ return this._organizationInfo;
3884
+ }
3885
+ /**
3886
+ * API 엔드포인트 반환
3887
+ */
3888
+ getApiEndpoint() {
3889
+ return endpoints.getBaseUrl();
3890
+ }
3891
+ /**
3892
+ * 디버그 모드 여부 확인
3893
+ */
3894
+ isDebugMode() {
3895
+ return this._config?.debug || false;
3896
+ }
3897
+ /**
3898
+ * 활성화된 모듈 목록 반환
3899
+ */
3900
+ getEnabledModules() {
3901
+ return this._config?.modules || [];
3902
+ }
3903
+ /**
3904
+ * 특정 모듈이 활성화되어 있는지 확인
3905
+ */
3906
+ isModuleEnabled(moduleName) {
3907
+ return this.getEnabledModules().includes(moduleName);
3908
+ }
3909
+ /**
3910
+ * 설정 업데이트 (런타임)
3911
+ */
3912
+ updateConfig(updates) {
3913
+ if (!this._config) {
3914
+ throw new Error('Config module not initialized');
3915
+ }
3916
+ this._config = {
3917
+ ...this._config,
3918
+ ...updates
3919
+ };
3920
+ if (this.isDebugMode()) {
3921
+ console.log('🔄 Config updated', updates);
3922
+ }
3923
+ }
3924
+ /**
3925
+ * API 헤더 생성 (공통 유틸리티 사용)
3926
+ */
3927
+ getApiHeaders() {
3928
+ if (!this._config?.apiKey) {
3929
+ throw new Error('API key not available');
3930
+ }
3931
+ return ApiHeaders.create(this._config.apiKey);
3932
+ }
3933
+ }
3934
+
3935
+ /**
3936
+ * AdStage SDK - 이벤트용 디바이스 정보 수집기
3937
+ * Events API 스키마에 최적화된 디바이스 정보 수집
3938
+ * DeviceInfoCollector를 재사용하여 중복 제거
3939
+ */
3940
+ class EventDeviceCollector {
3941
+ /**
3942
+ * Events API용 디바이스 정보 반환
3943
+ * TrackEventDto.DeviceInfoInput 형태에 맞춤
3944
+ */
3945
+ static getDeviceInfo() {
3946
+ if (!DOMUtils.isBrowser()) {
3947
+ return {
3948
+ category: 'other',
3949
+ platform: 'SSR',
3950
+ model: 'SSR',
3951
+ appVersion: ConfigUtils.getAppVersion(), // AdStage.init()에서 설정 또는 기본값 '1.0.0'
3952
+ osVersion: 'SSR'
3953
+ };
3954
+ }
3955
+ const userAgent = navigator.userAgent.toLowerCase();
3956
+ let category = 'desktop';
3957
+ // 디바이스 카테고리 판별 (DeviceInfoCollector 재사용)
3958
+ if (/tablet|ipad/.test(userAgent)) {
3959
+ category = 'tablet';
3960
+ }
3961
+ else if (DeviceInfoCollector.isMobile()) {
3962
+ category = 'mobile';
3963
+ }
3964
+ // 플랫폼 정보 매핑 (Events API용)
3965
+ const platformType = DeviceInfoCollector.getPlatform();
3966
+ const platformString = EventDeviceCollector.mapPlatformForEvents(platformType);
3967
+ return {
3968
+ category,
3969
+ platform: platformString,
3970
+ model: navigator.platform,
3971
+ appVersion: ConfigUtils.getAppVersion(), // AdStage.init()에서 설정 또는 기본값 '1.0.0'
3972
+ osVersion: navigator.platform
3973
+ };
3974
+ }
3975
+ /**
3976
+ * 플랫폼 타입을 Events API용 문자열로 매핑
3977
+ */
3978
+ static mapPlatformForEvents(platformType) {
3979
+ switch (platformType) {
3980
+ case 'ios': return 'ios';
3981
+ case 'android': return 'android';
3982
+ case 'web': return 'mobile-web';
3983
+ case 'desktop': return 'desktop-web';
3984
+ default: return 'unknown';
3985
+ }
3986
+ }
3987
+ /**
3988
+ * 디바이스 상세 정보 (디버깅용)
3989
+ */
3990
+ static getDetailedInfo() {
3991
+ if (!DOMUtils.isBrowser()) {
3992
+ return {
3993
+ userAgent: 'SSR',
3994
+ language: 'ko-KR',
3995
+ platform: 'SSR',
3996
+ cookieEnabled: false,
3997
+ onLine: true,
3998
+ screenResolution: '0x0',
3999
+ viewportSize: '0x0'
4000
+ };
4001
+ }
4002
+ const viewportInfo = DOMUtils.getViewportInfo();
4003
+ return {
4004
+ userAgent: navigator.userAgent,
4005
+ language: navigator.language || 'ko-KR',
4006
+ platform: navigator.platform,
4007
+ cookieEnabled: navigator.cookieEnabled,
4008
+ onLine: navigator.onLine,
4009
+ screenResolution: `${screen.width}x${screen.height}`,
4010
+ viewportSize: `${viewportInfo.width}x${viewportInfo.height}`
4011
+ };
4012
+ }
4013
+ }
4014
+
4015
+ /**
4016
+ * AdStage SDK - 이벤트용 사용자 정보 수집기
4017
+ * Events API 스키마에 최적화된 사용자 정보 수집
4018
+ */
4019
+ class EventUserCollector {
4020
+ /**
4021
+ * Events API용 사용자 정보 반환
4022
+ * TrackEventDto.UserAttributesInput 형태에 맞춤
4023
+ */
4024
+ static getUserInfo() {
4025
+ const baseInfo = EventUserCollector.getBaseUserInfo();
4026
+ // 설정된 사용자 속성과 병합
4027
+ return {
4028
+ ...baseInfo,
4029
+ ...EventUserCollector._userProperties
4030
+ };
4031
+ }
4032
+ /**
4033
+ * 기본 사용자 정보 수집 (브라우저 기반)
4034
+ */
4035
+ static getBaseUserInfo() {
4036
+ if (!DOMUtils.isBrowser()) {
4037
+ return {
4038
+ language: 'ko-KR',
4039
+ country: 'KR'
4040
+ };
4041
+ }
4042
+ // 브라우저 언어 설정에서 국가 추출
4043
+ const language = navigator.language || 'ko-KR';
4044
+ const country = EventUserCollector.extractCountryFromLanguage(language);
4045
+ return {
4046
+ language,
4047
+ country
4048
+ };
4049
+ }
4050
+ /**
4051
+ * 언어 코드에서 국가 추출
4052
+ */
4053
+ static extractCountryFromLanguage(language) {
4054
+ const countryMap = {
4055
+ 'ko': 'KR',
4056
+ 'ko-KR': 'KR',
4057
+ 'en': 'US',
4058
+ 'en-US': 'US',
4059
+ 'en-GB': 'GB',
4060
+ 'ja': 'JP',
4061
+ 'ja-JP': 'JP',
4062
+ 'zh': 'CN',
4063
+ 'zh-CN': 'CN',
4064
+ 'zh-TW': 'TW',
4065
+ 'de': 'DE',
4066
+ 'de-DE': 'DE',
4067
+ 'fr': 'FR',
4068
+ 'fr-FR': 'FR'
4069
+ };
4070
+ // 정확한 매칭 시도
4071
+ if (countryMap[language]) {
4072
+ return countryMap[language];
4073
+ }
4074
+ // 언어 코드만 추출해서 매칭
4075
+ const langCode = language.split('-')[0];
4076
+ return countryMap[langCode] || 'KR';
4077
+ }
4078
+ /**
4079
+ * 사용자 속성 설정
4080
+ */
4081
+ static setUserProperties(properties) {
4082
+ EventUserCollector._userProperties = {
4083
+ ...EventUserCollector._userProperties,
4084
+ ...properties
4085
+ };
4086
+ }
4087
+ /**
4088
+ * 특정 사용자 속성 설정
4089
+ */
4090
+ static setUserProperty(key, value) {
4091
+ EventUserCollector._userProperties[key] = value;
4092
+ }
4093
+ /**
4094
+ * 사용자 속성 초기화
4095
+ */
4096
+ static clearUserProperties() {
4097
+ EventUserCollector._userProperties = {};
4098
+ }
4099
+ /**
4100
+ * 현재 설정된 사용자 속성 반환
4101
+ */
4102
+ static getCurrentUserProperties() {
4103
+ return { ...EventUserCollector._userProperties };
4104
+ }
4105
+ /**
4106
+ * 사용자 지역 정보 추정 (타임존 기반)
4107
+ */
4108
+ static estimateLocation() {
4109
+ if (!DOMUtils.isBrowser()) {
4110
+ return {
4111
+ timezone: 'UTC',
4112
+ estimatedCountry: 'KR'
4113
+ };
4114
+ }
4115
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
4116
+ // 타임존 기반 국가 추정
4117
+ const timezoneCountryMap = {
4118
+ 'Asia/Seoul': 'KR',
4119
+ 'Asia/Tokyo': 'JP',
4120
+ 'Asia/Shanghai': 'CN',
4121
+ 'Asia/Hong_Kong': 'HK',
4122
+ 'Asia/Taipei': 'TW',
4123
+ 'America/New_York': 'US',
4124
+ 'America/Los_Angeles': 'US',
4125
+ 'Europe/London': 'GB',
4126
+ 'Europe/Berlin': 'DE',
4127
+ 'Europe/Paris': 'FR'
4128
+ };
4129
+ return {
4130
+ timezone,
4131
+ estimatedCountry: timezoneCountryMap[timezone] || 'KR'
4132
+ };
4133
+ }
4134
+ }
4135
+ EventUserCollector._userProperties = {};
4136
+
4137
+ /**
4138
+ * AdStage SDK - 이벤트용 세션 관리자
4139
+ * 세션 ID 생성 및 사용자 ID 관리
4140
+ */
4141
+ class EventSessionManager {
4142
+ /**
4143
+ * 세션 ID 생성 및 반환 (SSR 안전)
4144
+ */
4145
+ static getSessionId() {
4146
+ if (!DOMUtils.isBrowser()) {
4147
+ return 'ssr_session_' + Date.now();
4148
+ }
4149
+ const stored = sessionStorage.getItem('adstage_session_id');
4150
+ if (stored) {
4151
+ return stored;
4152
+ }
4153
+ const sessionId = 'session_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
4154
+ sessionStorage.setItem('adstage_session_id', sessionId);
4155
+ // 세션 시작 시간 기록
4156
+ if (!EventSessionManager._sessionStartTime) {
4157
+ EventSessionManager._sessionStartTime = Date.now();
4158
+ if (DOMUtils.isBrowser()) {
4159
+ sessionStorage.setItem('adstage_session_start', String(EventSessionManager._sessionStartTime));
4160
+ }
4161
+ }
4162
+ return sessionId;
4163
+ }
4164
+ /**
4165
+ * 사용자 ID 설정
4166
+ */
4167
+ static setUserId(userId) {
4168
+ EventSessionManager._userId = userId;
4169
+ if (DOMUtils.isBrowser()) {
4170
+ localStorage.setItem('adstage_user_id', userId);
4171
+ }
4172
+ }
4173
+ /**
4174
+ * 현재 사용자 ID 반환
4175
+ */
4176
+ static getUserId() {
4177
+ // 메모리에 있는 값 우선 반환
4178
+ if (EventSessionManager._userId) {
4179
+ return EventSessionManager._userId;
4180
+ }
4181
+ // 로컬 스토리지에서 복원
4182
+ if (DOMUtils.isBrowser()) {
4183
+ const stored = localStorage.getItem('adstage_user_id');
4184
+ if (stored) {
4185
+ EventSessionManager._userId = stored;
4186
+ return stored;
4187
+ }
4188
+ }
4189
+ return undefined;
4190
+ }
4191
+ /**
4192
+ * 사용자 ID 제거
4193
+ */
4194
+ static clearUserId() {
4195
+ EventSessionManager._userId = undefined;
4196
+ if (DOMUtils.isBrowser()) {
4197
+ localStorage.removeItem('adstage_user_id');
4198
+ }
4199
+ }
4200
+ /**
4201
+ * 세션 정보 전체 반환
4202
+ */
4203
+ static getSessionInfo() {
4204
+ const sessionId = EventSessionManager.getSessionId();
4205
+ const userId = EventSessionManager.getUserId();
4206
+ const isNewSession = EventSessionManager.isNewSession();
4207
+ const sessionDuration = EventSessionManager.getSessionDuration();
4208
+ return {
4209
+ sessionId,
4210
+ userId,
4211
+ sessionDuration,
4212
+ isNewSession
4213
+ };
4214
+ }
4215
+ /**
4216
+ * 새 세션인지 확인
4217
+ */
4218
+ static isNewSession() {
4219
+ if (!DOMUtils.isBrowser())
4220
+ return true;
4221
+ const sessionId = sessionStorage.getItem('adstage_session_id');
4222
+ const sessionStart = sessionStorage.getItem('adstage_session_start');
4223
+ return !sessionId || !sessionStart;
4224
+ }
4225
+ /**
4226
+ * 세션 지속 시간 반환 (ms)
4227
+ */
4228
+ static getSessionDuration() {
4229
+ if (!DOMUtils.isBrowser())
4230
+ return 0;
4231
+ const sessionStart = sessionStorage.getItem('adstage_session_start');
4232
+ if (!sessionStart)
4233
+ return 0;
4234
+ return Date.now() - parseInt(sessionStart, 10);
4235
+ }
4236
+ /**
4237
+ * 세션 새로고침 (새로운 세션 ID 생성)
4238
+ */
4239
+ static refreshSession() {
4240
+ if (DOMUtils.isBrowser()) {
4241
+ sessionStorage.removeItem('adstage_session_id');
4242
+ sessionStorage.removeItem('adstage_session_start');
4243
+ }
4244
+ EventSessionManager._sessionStartTime = undefined;
4245
+ return EventSessionManager.getSessionId();
4246
+ }
4247
+ /**
4248
+ * 세션 만료 확인 (24시간 기준)
4249
+ */
4250
+ static isSessionExpired(maxDurationHours = 24) {
4251
+ const duration = EventSessionManager.getSessionDuration();
4252
+ const maxDuration = maxDurationHours * 60 * 60 * 1000; // ms 변환
4253
+ return duration > maxDuration;
4254
+ }
4255
+ /**
4256
+ * 세션 통계 반환 (디버깅용)
4257
+ */
4258
+ static getSessionStats() {
4259
+ const sessionId = EventSessionManager.getSessionId();
4260
+ const userId = EventSessionManager.getUserId();
4261
+ const duration = EventSessionManager.getSessionDuration();
4262
+ const isExpired = EventSessionManager.isSessionExpired();
4263
+ const isNewSession = EventSessionManager.isNewSession();
4264
+ let startTime;
4265
+ if (DOMUtils.isBrowser()) {
4266
+ const stored = sessionStorage.getItem('adstage_session_start');
4267
+ startTime = stored ? parseInt(stored, 10) : undefined;
4268
+ }
4269
+ return {
4270
+ sessionId,
4271
+ userId,
4272
+ startTime,
4273
+ duration,
4274
+ isExpired,
4275
+ isNewSession
4276
+ };
4277
+ }
4278
+ }
4279
+
4280
+ /**
4281
+ * AdStage SDK - Events 모듈
4282
+ * 이벤트 추적 시스템
4283
+ */
4284
+ class EventsModule {
4285
+ constructor() {
4286
+ this._isReady = false;
4287
+ this._config = null;
4288
+ }
4289
+ /**
4290
+ * Events 모듈 초기화 (동기)
4291
+ */
4292
+ init(config) {
4293
+ this._config = config;
4294
+ this._isReady = true;
4295
+ if (config.debug) {
4296
+ console.log('📊 Events module initialized');
4297
+ }
4298
+ }
4299
+ /**
4300
+ * 모듈 준비 상태 확인
4301
+ */
4302
+ isReady() {
4303
+ return this._isReady;
4304
+ }
4305
+ /**
4306
+ * 모듈 설정 반환
4307
+ */
4308
+ getConfig() {
4309
+ return this._config;
4310
+ }
4311
+ /**
4312
+ * 사용자 ID 설정
4313
+ */
4314
+ setUserId(userId) {
4315
+ EventSessionManager.setUserId(userId);
4316
+ if (this._config?.debug) {
4317
+ console.log('👤 User ID set:', userId);
4318
+ }
4319
+ }
4320
+ /**
4321
+ * 현재 사용자 ID 반환
4322
+ */
4323
+ getUserId() {
4324
+ return EventSessionManager.getUserId();
4325
+ }
4326
+ /**
4327
+ * 이벤트 추적
4328
+ * @param eventName 이벤트 이름
4329
+ * @param properties 이벤트 속성
4330
+ */
4331
+ async track(eventName, properties) {
4332
+ if (!this._isReady) {
4333
+ console.warn('Events module not initialized. Call AdStage.init() first.');
4334
+ return;
4335
+ }
4336
+ if (!this._config?.apiKey) {
4337
+ console.warn('API key not configured for event tracking.');
4338
+ return;
4339
+ }
4340
+ try {
4341
+ const eventData = {
4342
+ eventName,
4343
+ userId: EventSessionManager.getUserId(),
4344
+ sessionId: EventSessionManager.getSessionId(),
4345
+ device: EventDeviceCollector.getDeviceInfo(),
4346
+ user: EventUserCollector.getUserInfo(),
4347
+ params: properties || {}
4348
+ };
4349
+ await this.sendEventToServer(eventData);
4350
+ if (this._config.debug) {
4351
+ console.log('✅ Event tracked:', eventName, properties);
4352
+ }
4353
+ }
4354
+ catch (error) {
4355
+ console.error('❌ Failed to track event:', error);
4356
+ if (this._config.debug) {
4357
+ console.error('Event data:', { eventName, properties });
4358
+ }
4359
+ }
4360
+ }
4361
+ /**
4362
+ * 페이지 뷰 이벤트 (track의 편의 메소드)
4363
+ */
4364
+ async pageView(pageData) {
4365
+ const properties = {};
4366
+ if (pageData?.page)
4367
+ properties.page = pageData.page;
4368
+ if (pageData?.title)
4369
+ properties.title = pageData.title;
4370
+ if (pageData?.category)
4371
+ properties.category = pageData.category;
4372
+ // pageData의 다른 속성들도 포함
4373
+ if (pageData) {
4374
+ Object.keys(pageData).forEach(key => {
4375
+ if (key !== 'page' && key !== 'title' && key !== 'category') {
4376
+ properties[key] = pageData[key];
4377
+ }
4378
+ });
4379
+ }
4380
+ // 현재 페이지 정보 자동 수집
4381
+ if (typeof window !== 'undefined') {
4382
+ if (!properties.page)
4383
+ properties.page = window.location.pathname;
4384
+ if (!properties.title)
4385
+ properties.title = document.title;
4386
+ properties.url = window.location.href;
4387
+ properties.referrer = document.referrer;
4388
+ }
4389
+ await this.track('page_view', properties);
4390
+ }
4391
+ /**
4392
+ * 서버에 이벤트 전송
4393
+ */
4394
+ async sendEventToServer(eventData) {
4395
+ const response = await fetch(endpoints.events.track(), {
4396
+ method: 'POST',
4397
+ headers: {
4398
+ ...ApiHeaders.create(this._config.apiKey),
4399
+ 'Content-Type': 'application/json'
4400
+ },
4401
+ body: JSON.stringify(eventData)
4402
+ });
4403
+ if (!response.ok) {
4404
+ throw new Error(`Event tracking failed: ${response.status} ${response.statusText}`);
4405
+ }
4406
+ }
4407
+ }
4408
+
4409
+ /**
4410
+ * AdStage SDK - 메인 네임스페이스 클래스
4411
+ * v2.0.0 - 확장 가능한 모듈 아키텍처
4412
+ */
4413
+ class AdStage {
4414
+ constructor() {
4415
+ this._isInitialized = false;
4416
+ this._config = null;
4417
+ // 모듈 초기화 (ads, config는 완전 구현, events는 기본 구조)
4418
+ this.config = new ConfigModule();
4419
+ this.ads = new AdsModule();
4420
+ this.events = new EventsModule();
4421
+ }
4422
+ /**
4423
+ * AdStage SDK 초기화 (동기)
4424
+ */
4425
+ static init(config) {
4426
+ if (!AdStage.instance) {
4427
+ AdStage.instance = new AdStage();
4428
+ }
4429
+ const instance = AdStage.instance;
4430
+ // 설정 검증
4431
+ if (!config.apiKey) {
4432
+ throw new Error('API key is required for AdStage initialization');
4433
+ }
4434
+ // 설정 저장 (서버 검증 없음)
4435
+ instance._config = {
4436
+ timeout: 30000,
4437
+ debug: false,
4438
+ modules: ['ads', 'events', 'config'],
4439
+ ...config
4440
+ };
4441
+ // 🔧 baseUrl이 설정된 경우 전역 endpoints 객체 업데이트
4442
+ if (instance._config.baseUrl) {
4443
+ endpoints.setBaseUrl(instance._config.baseUrl);
4444
+ if (config.debug) {
4445
+ console.log('🌐 API endpoint configured:', instance._config.baseUrl);
4446
+ }
4447
+ }
4448
+ // 모듈 동기 초기화
4449
+ const enabledModules = instance._config.modules || ['ads', 'events', 'config'];
4450
+ for (const moduleName of enabledModules) {
4451
+ const module = instance[moduleName];
4452
+ if (module && typeof module.init === 'function') {
4453
+ module.init(instance._config);
4454
+ }
4455
+ }
4456
+ instance._isInitialized = true;
4457
+ if (config.debug) {
4458
+ console.log('🚀 AdStage SDK initialized (sync mode)', {
4459
+ version: '2.0.0',
4460
+ modules: enabledModules,
4461
+ apiKey: config.apiKey.substring(0, 8) + '...',
4462
+ mode: 'development'
4463
+ });
4464
+ }
4465
+ }
4466
+ /**
4467
+ * SDK 초기화 상태 확인
4468
+ */
4469
+ static isReady() {
4470
+ return AdStage.instance?._isInitialized || false;
4471
+ }
4472
+ /**
4473
+ * 현재 설정 반환
4474
+ */
4475
+ static getConfig() {
4476
+ return AdStage.instance?._config || null;
4477
+ }
4478
+ /**
4479
+ * SDK 인스턴스 반환 (공개 메소드로 변경)
4480
+ */
4481
+ static getInstance() {
4482
+ if (!AdStage.instance) {
4483
+ throw new Error('AdStage not initialized. Call AdStage.init() first.');
4484
+ }
4485
+ return AdStage.instance;
4486
+ }
4487
+ /**
4488
+ * 편의성을 위한 정적 모듈 접근자들
4489
+ */
4490
+ static get ads() {
4491
+ return AdStage.getInstance().ads;
4492
+ }
4493
+ static get events() {
4494
+ return AdStage.getInstance().events;
4495
+ }
4496
+ static get config() {
4497
+ return AdStage.getInstance().config;
4498
+ }
4499
+ /**
4500
+ * SDK 리셋 (테스트용)
4501
+ */
4502
+ static reset() {
4503
+ if (AdStage.instance) {
4504
+ AdStage.instance._isInitialized = false;
4505
+ AdStage.instance._config = null;
4506
+ }
4507
+ }
4508
+ }
4509
+ /**
4510
+ * 디버그용 메서드들
4511
+ */
4512
+ AdStage.debug = {
4513
+ /**
4514
+ * 모든 viewable 추적 데이터 초기화
4515
+ */
4516
+ clearAllViewable: () => {
4517
+ ViewableEventTracker.clear();
4518
+ console.log('✅ AdStage Debug: 모든 viewable 추적 데이터 초기화됨');
4519
+ },
4520
+ /**
4521
+ * 특정 광고의 viewable 추적 초기화
4522
+ */
4523
+ clearAdViewable: (adId, slotId) => {
4524
+ ViewableEventTracker.clearAdViewable(adId, slotId);
4525
+ console.log(`✅ AdStage Debug: 광고 ${adId}(${slotId})의 viewable 추적 초기화됨`);
4526
+ },
4527
+ /**
4528
+ * 현재 추적 중인 viewable 상태 확인
4529
+ */
4530
+ getViewableStatus: () => {
4531
+ console.log('📊 AdStage Debug: 현재 viewable 추적 상태', {
4532
+ trackedCount: ViewableEventTracker.viewableTracker.size,
4533
+ trackedItems: Array.from(ViewableEventTracker.viewableTracker)
4534
+ });
4535
+ }
4536
+ };
4537
+
4538
+ /**
4539
+ * AdStage SDK - 전역 이벤트 함수들
4540
+ * Firebase Analytics와 유사한 간단한 API 제공
4541
+ */
4542
+ /**
4543
+ * 이벤트 추적 (메인 함수)
4544
+ * @param eventName 이벤트 이름
4545
+ * @param properties 이벤트 속성
4546
+ *
4547
+ * @example
4548
+ * track('purchase', {
4549
+ * transaction_id: 'T123',
4550
+ * value: 99.99,
4551
+ * currency: 'USD'
4552
+ * });
4553
+ */
4554
+ function track(eventName, properties) {
4555
+ if (!AdStage.isReady()) {
4556
+ console.warn('AdStage not initialized. Call AdStage.init() first.');
4557
+ return Promise.resolve();
4558
+ }
4559
+ return AdStage.events.track(eventName, properties);
4560
+ }
4561
+ /**
4562
+ * 페이지 뷰 추적
4563
+ * @param pageData 페이지 정보
4564
+ *
4565
+ * @example
4566
+ * pageView({ page: '/products', title: 'Products Page' });
4567
+ */
4568
+ function pageView(pageData) {
4569
+ if (!AdStage.isReady()) {
4570
+ console.warn('AdStage not initialized. Call AdStage.init() first.');
4571
+ return Promise.resolve();
4572
+ }
4573
+ return AdStage.events.pageView(pageData);
4574
+ }
4575
+ /**
4576
+ * 사용자 ID 설정
4577
+ * @param userId 사용자 ID
4578
+ *
4579
+ * @example
4580
+ * setUserId('user123');
4581
+ */
4582
+ function setUserId(userId) {
4583
+ if (!AdStage.isReady()) {
4584
+ console.warn('AdStage not initialized. Call AdStage.init() first.');
4585
+ return;
4586
+ }
4587
+ AdStage.events.setUserId(userId);
4588
+ }
4589
+ /**
4590
+ * 현재 사용자 ID 반환
4591
+ */
4592
+ function getUserId() {
4593
+ if (!AdStage.isReady()) {
4594
+ console.warn('AdStage not initialized. Call AdStage.init() first.');
4595
+ return undefined;
4596
+ }
4597
+ return AdStage.events.getUserId();
4598
+ }
4599
+
4600
+ var jsxRuntime = {exports: {}};
4601
+
4602
+ var reactJsxRuntime_production_min = {};
4603
+
4604
+ var react = {exports: {}};
4605
+
4606
+ var react_production_min = {};
4607
+
4608
+ /**
4609
+ * @license React
4610
+ * react.production.min.js
4611
+ *
4612
+ * Copyright (c) Facebook, Inc. and its affiliates.
4613
+ *
4614
+ * This source code is licensed under the MIT license found in the
4615
+ * LICENSE file in the root directory of this source tree.
4616
+ */
4617
+
4618
+ var hasRequiredReact_production_min;
4619
+
4620
+ function requireReact_production_min () {
4621
+ if (hasRequiredReact_production_min) return react_production_min;
4622
+ hasRequiredReact_production_min = 1;
4623
+ var l=Symbol.for("react.element"),n=Symbol.for("react.portal"),p=Symbol.for("react.fragment"),q=Symbol.for("react.strict_mode"),r=Symbol.for("react.profiler"),t=Symbol.for("react.provider"),u=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),w=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),z=Symbol.iterator;function A(a){if(null===a||"object"!==typeof a)return null;a=z&&a[z]||a["@@iterator"];return "function"===typeof a?a:null}
4624
+ var B={isMounted:function(){return !1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,D={};function E(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B;}E.prototype.isReactComponent={};
4625
+ E.prototype.setState=function(a,b){if("object"!==typeof a&&"function"!==typeof a&&null!=a)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,a,b,"setState");};E.prototype.forceUpdate=function(a){this.updater.enqueueForceUpdate(this,a,"forceUpdate");};function F(){}F.prototype=E.prototype;function G(a,b,e){this.props=a;this.context=b;this.refs=D;this.updater=e||B;}var H=G.prototype=new F;
4626
+ H.constructor=G;C(H,E.prototype);H.isPureReactComponent=!0;var I=Array.isArray,J=Object.prototype.hasOwnProperty,K={current:null},L={key:!0,ref:!0,__self:!0,__source:!0};
4627
+ function M(a,b,e){var d,c={},k=null,h=null;if(null!=b)for(d in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(k=""+b.key),b)J.call(b,d)&&!L.hasOwnProperty(d)&&(c[d]=b[d]);var g=arguments.length-2;if(1===g)c.children=e;else if(1<g){for(var f=Array(g),m=0;m<g;m++)f[m]=arguments[m+2];c.children=f;}if(a&&a.defaultProps)for(d in g=a.defaultProps,g)void 0===c[d]&&(c[d]=g[d]);return {$$typeof:l,type:a,key:k,ref:h,props:c,_owner:K.current}}
4628
+ function N(a,b){return {$$typeof:l,type:a.type,key:b,ref:a.ref,props:a.props,_owner:a._owner}}function O(a){return "object"===typeof a&&null!==a&&a.$$typeof===l}function escape(a){var b={"=":"=0",":":"=2"};return "$"+a.replace(/[=:]/g,function(a){return b[a]})}var P=/\/+/g;function Q(a,b){return "object"===typeof a&&null!==a&&null!=a.key?escape(""+a.key):b.toString(36)}
4629
+ function R(a,b,e,d,c){var k=typeof a;if("undefined"===k||"boolean"===k)a=null;var h=!1;if(null===a)h=!0;else switch(k){case "string":case "number":h=!0;break;case "object":switch(a.$$typeof){case l:case n:h=!0;}}if(h)return h=a,c=c(h),a=""===d?"."+Q(h,0):d,I(c)?(e="",null!=a&&(e=a.replace(P,"$&/")+"/"),R(c,b,e,"",function(a){return a})):null!=c&&(O(c)&&(c=N(c,e+(!c.key||h&&h.key===c.key?"":(""+c.key).replace(P,"$&/")+"/")+a)),b.push(c)),1;h=0;d=""===d?".":d+":";if(I(a))for(var g=0;g<a.length;g++){k=
4630
+ a[g];var f=d+Q(k,g);h+=R(k,b,e,f,c);}else if(f=A(a),"function"===typeof f)for(a=f.call(a),g=0;!(k=a.next()).done;)k=k.value,f=d+Q(k,g++),h+=R(k,b,e,f,c);else if("object"===k)throw b=String(a),Error("Objects are not valid as a React child (found: "+("[object Object]"===b?"object with keys {"+Object.keys(a).join(", ")+"}":b)+"). If you meant to render a collection of children, use an array instead.");return h}
4631
+ function S(a,b,e){if(null==a)return a;var d=[],c=0;R(a,d,"","",function(a){return b.call(e,a,c++)});return d}function T(a){if(-1===a._status){var b=a._result;b=b();b.then(function(b){if(0===a._status||-1===a._status)a._status=1,a._result=b;},function(b){if(0===a._status||-1===a._status)a._status=2,a._result=b;});-1===a._status&&(a._status=0,a._result=b);}if(1===a._status)return a._result.default;throw a._result;}
4632
+ var U={current:null},V={transition:null},W={ReactCurrentDispatcher:U,ReactCurrentBatchConfig:V,ReactCurrentOwner:K};function X(){throw Error("act(...) is not supported in production builds of React.");}
4633
+ react_production_min.Children={map:S,forEach:function(a,b,e){S(a,function(){b.apply(this,arguments);},e);},count:function(a){var b=0;S(a,function(){b++;});return b},toArray:function(a){return S(a,function(a){return a})||[]},only:function(a){if(!O(a))throw Error("React.Children.only expected to receive a single React element child.");return a}};react_production_min.Component=E;react_production_min.Fragment=p;react_production_min.Profiler=r;react_production_min.PureComponent=G;react_production_min.StrictMode=q;react_production_min.Suspense=w;
4634
+ react_production_min.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=W;react_production_min.act=X;
4635
+ react_production_min.cloneElement=function(a,b,e){if(null===a||void 0===a)throw Error("React.cloneElement(...): The argument must be a React element, but you passed "+a+".");var d=C({},a.props),c=a.key,k=a.ref,h=a._owner;if(null!=b){void 0!==b.ref&&(k=b.ref,h=K.current);void 0!==b.key&&(c=""+b.key);if(a.type&&a.type.defaultProps)var g=a.type.defaultProps;for(f in b)J.call(b,f)&&!L.hasOwnProperty(f)&&(d[f]=void 0===b[f]&&void 0!==g?g[f]:b[f]);}var f=arguments.length-2;if(1===f)d.children=e;else if(1<f){g=Array(f);
4636
+ for(var m=0;m<f;m++)g[m]=arguments[m+2];d.children=g;}return {$$typeof:l,type:a.type,key:c,ref:k,props:d,_owner:h}};react_production_min.createContext=function(a){a={$$typeof:u,_currentValue:a,_currentValue2:a,_threadCount:0,Provider:null,Consumer:null,_defaultValue:null,_globalName:null};a.Provider={$$typeof:t,_context:a};return a.Consumer=a};react_production_min.createElement=M;react_production_min.createFactory=function(a){var b=M.bind(null,a);b.type=a;return b};react_production_min.createRef=function(){return {current:null}};
4637
+ react_production_min.forwardRef=function(a){return {$$typeof:v,render:a}};react_production_min.isValidElement=O;react_production_min.lazy=function(a){return {$$typeof:y,_payload:{_status:-1,_result:a},_init:T}};react_production_min.memo=function(a,b){return {$$typeof:x,type:a,compare:void 0===b?null:b}};react_production_min.startTransition=function(a){var b=V.transition;V.transition={};try{a();}finally{V.transition=b;}};react_production_min.unstable_act=X;react_production_min.useCallback=function(a,b){return U.current.useCallback(a,b)};react_production_min.useContext=function(a){return U.current.useContext(a)};
4638
+ react_production_min.useDebugValue=function(){};react_production_min.useDeferredValue=function(a){return U.current.useDeferredValue(a)};react_production_min.useEffect=function(a,b){return U.current.useEffect(a,b)};react_production_min.useId=function(){return U.current.useId()};react_production_min.useImperativeHandle=function(a,b,e){return U.current.useImperativeHandle(a,b,e)};react_production_min.useInsertionEffect=function(a,b){return U.current.useInsertionEffect(a,b)};react_production_min.useLayoutEffect=function(a,b){return U.current.useLayoutEffect(a,b)};
4639
+ react_production_min.useMemo=function(a,b){return U.current.useMemo(a,b)};react_production_min.useReducer=function(a,b,e){return U.current.useReducer(a,b,e)};react_production_min.useRef=function(a){return U.current.useRef(a)};react_production_min.useState=function(a){return U.current.useState(a)};react_production_min.useSyncExternalStore=function(a,b,e){return U.current.useSyncExternalStore(a,b,e)};react_production_min.useTransition=function(){return U.current.useTransition()};react_production_min.version="18.3.1";
4640
+ return react_production_min;
4641
+ }
4642
+
4643
+ {
4644
+ react.exports = requireReact_production_min();
4645
+ }
4646
+
4647
+ var reactExports = react.exports;
4648
+
4649
+ /**
4650
+ * @license React
4651
+ * react-jsx-runtime.production.min.js
4652
+ *
4653
+ * Copyright (c) Facebook, Inc. and its affiliates.
4654
+ *
4655
+ * This source code is licensed under the MIT license found in the
4656
+ * LICENSE file in the root directory of this source tree.
4657
+ */
4658
+
4659
+ var hasRequiredReactJsxRuntime_production_min;
4660
+
4661
+ function requireReactJsxRuntime_production_min () {
4662
+ if (hasRequiredReactJsxRuntime_production_min) return reactJsxRuntime_production_min;
4663
+ hasRequiredReactJsxRuntime_production_min = 1;
4664
+ var f=reactExports,k=Symbol.for("react.element"),l=Symbol.for("react.fragment"),m=Object.prototype.hasOwnProperty,n=f.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,p={key:!0,ref:!0,__self:!0,__source:!0};
4665
+ function q(c,a,g){var b,d={},e=null,h=null;void 0!==g&&(e=""+g);void 0!==a.key&&(e=""+a.key);void 0!==a.ref&&(h=a.ref);for(b in a)m.call(a,b)&&!p.hasOwnProperty(b)&&(d[b]=a[b]);if(c&&c.defaultProps)for(b in a=c.defaultProps,a)void 0===d[b]&&(d[b]=a[b]);return {$$typeof:k,type:c,key:e,ref:h,props:d,_owner:n.current}}reactJsxRuntime_production_min.Fragment=l;reactJsxRuntime_production_min.jsx=q;reactJsxRuntime_production_min.jsxs=q;
4666
+ return reactJsxRuntime_production_min;
4667
+ }
4668
+
4669
+ {
4670
+ jsxRuntime.exports = requireReactJsxRuntime_production_min();
4671
+ }
4672
+
4673
+ var jsxRuntimeExports = jsxRuntime.exports;
4674
+
4675
+ const AdStageContext = reactExports.createContext(null);
4676
+ function AdStageProvider({ children, config }) {
4677
+ const [isInitialized, setIsInitialized] = reactExports.useState(false);
4678
+ const [currentConfig, setCurrentConfig] = reactExports.useState(null);
4679
+ const [error, setError] = reactExports.useState(null);
4680
+ const initialize = (newConfig) => {
4681
+ try {
4682
+ setError(null);
4683
+ // 기존 인스턴스가 있으면 리셋
4684
+ if (isInitialized) {
4685
+ AdStage.reset();
4686
+ }
4687
+ AdStage.init(newConfig);
4688
+ setCurrentConfig(newConfig);
4689
+ setIsInitialized(true);
4690
+ if (newConfig.debug) {
4691
+ console.log('✅ AdStage SDK initialized successfully via React Provider');
4692
+ }
4693
+ }
4694
+ catch (err) {
4695
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
4696
+ setError(errorMessage);
4697
+ console.error('❌ AdStage SDK initialization failed:', err);
4698
+ setIsInitialized(false);
4699
+ setCurrentConfig(null);
4700
+ }
4701
+ };
4702
+ const reset = () => {
4703
+ try {
4704
+ AdStage.reset();
4705
+ setIsInitialized(false);
4706
+ setCurrentConfig(null);
4707
+ setError(null);
4708
+ }
4709
+ catch (err) {
4710
+ console.error('❌ AdStage SDK reset failed:', err);
4711
+ }
4712
+ };
4713
+ // 자동 초기화
4714
+ reactExports.useEffect(() => {
4715
+ if (config && !isInitialized) {
4716
+ initialize(config);
4717
+ }
4718
+ }, [config, isInitialized]);
4719
+ const contextValue = {
4720
+ isInitialized,
4721
+ config: currentConfig,
4722
+ initialize,
4723
+ reset,
4724
+ error
4725
+ };
4726
+ return (jsxRuntimeExports.jsx(AdStageContext.Provider, { value: contextValue, children: children }));
4727
+ }
4728
+ /**
4729
+ * AdStage Context Hook
4730
+ * AdStageProvider 내에서 SDK 상태에 접근할 수 있습니다.
4731
+ */
4732
+ function useAdStageContext() {
4733
+ const context = reactExports.useContext(AdStageContext);
4734
+ if (!context) {
4735
+ throw new Error('useAdStageContext must be used within an AdStageProvider');
4736
+ }
4737
+ return context;
4738
+ }
4739
+ /**
4740
+ * AdStage Instance Hook
4741
+ * 초기화된 AdStage 인스턴스에 직접 접근할 수 있습니다.
4742
+ */
4743
+ function useAdStageInstance() {
4744
+ const { isInitialized } = useAdStageContext();
4745
+ if (!isInitialized) {
4746
+ console.warn('AdStage SDK is not initialized. Please call initialize() first or provide config to AdStageProvider.');
4747
+ return null;
4748
+ }
4749
+ return AdStage;
4750
+ }
4751
+
4752
+ /**
4753
+ * AdStage Web SDK
4754
+ * 네임스페이스 아키텍처 기반 SDK
4755
+ */
4756
+ // 메인 네임스페이스 클래스
4757
+ // 버전 정보
4758
+ const SDK_VERSION = '2.0.0';
4759
+ const SUPPORTED_MODULES = ['ads', 'events', 'config'];
4760
+ // 브라우저 환경에서 전역 객체로 노출 (디버깅용)
4761
+ if (typeof window !== 'undefined') {
4762
+ window.AdStage = AdStage;
4763
+ }
4764
+
4765
+ exports.AdStage = AdStage;
4766
+ exports.AdStageProvider = AdStageProvider;
4767
+ exports.SDK_VERSION = SDK_VERSION;
4768
+ exports.SUPPORTED_MODULES = SUPPORTED_MODULES;
4769
+ exports.getUserId = getUserId;
4770
+ exports.pageView = pageView;
4771
+ exports.setUserId = setUserId;
4772
+ exports.track = track;
4773
+ exports.useAdStageContext = useAdStageContext;
4774
+ exports.useAdStageInstance = useAdStageInstance;
4775
+
4776
+ }));