@adstage/web-sdk 2.6.1 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs.js +595 -60
- package/dist/index.d.ts +73 -23
- package/dist/index.esm.js +592 -61
- package/dist/index.standalone.js +592 -61
- package/dist/index.umd.js +4791 -0
- package/package.json +4 -2
- package/src/constants/endpoints.ts +0 -2
- package/src/events/global-events.ts +72 -0
- package/src/example/events/README.md +125 -0
- package/src/example/events/javascript/index-esm.html +159 -0
- package/src/example/events/javascript/index.html +151 -0
- package/src/example/events/nextjs/README.md +54 -0
- package/src/example/events/nextjs/next.config.js +8 -0
- package/src/example/events/nextjs/package.json +16 -0
- package/src/example/events/nextjs/pages/_app.js +15 -0
- package/src/example/events/nextjs/pages/index.js +139 -0
- package/src/example/events/react/.env +0 -0
- package/src/example/events/react/README.md +57 -0
- package/src/example/events/react/package.json +27 -0
- package/src/example/events/react/public/index.html +11 -0
- package/src/example/events/react/src/App.js +162 -0
- package/src/example/events/react/src/index.js +6 -0
- package/src/index.ts +3 -0
- package/src/managers/device-info-collector.ts +9 -4
- package/src/managers/events/event-device-collector.ts +106 -0
- package/src/managers/events/event-session-manager.ts +183 -0
- package/src/managers/events/event-user-collector.ts +166 -0
- package/src/modules/events/events-module.ts +118 -40
- package/src/types/config.ts +3 -0
- package/src/utils/config-utils.ts +69 -0
|
@@ -0,0 +1,4791 @@
|
|
|
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.3"';
|
|
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
|
+
*/
|
|
4329
|
+
setUserProperties(properties) {
|
|
4330
|
+
EventUserCollector.setUserProperties(properties);
|
|
4331
|
+
if (this._config?.debug) {
|
|
4332
|
+
console.log('👤 User properties set:', properties);
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
/**
|
|
4336
|
+
* 현재 사용자 속성 반환
|
|
4337
|
+
*/
|
|
4338
|
+
getUserProperties() {
|
|
4339
|
+
return EventUserCollector.getCurrentUserProperties();
|
|
4340
|
+
}
|
|
4341
|
+
/**
|
|
4342
|
+
* 이벤트 추적
|
|
4343
|
+
* @param eventName 이벤트 이름
|
|
4344
|
+
* @param properties 이벤트 속성
|
|
4345
|
+
*/
|
|
4346
|
+
async track(eventName, properties) {
|
|
4347
|
+
if (!this._isReady) {
|
|
4348
|
+
console.warn('Events module not initialized. Call AdStage.init() first.');
|
|
4349
|
+
return;
|
|
4350
|
+
}
|
|
4351
|
+
if (!this._config?.apiKey) {
|
|
4352
|
+
console.warn('API key not configured for event tracking.');
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
try {
|
|
4356
|
+
const eventData = {
|
|
4357
|
+
eventName,
|
|
4358
|
+
userId: EventSessionManager.getUserId(),
|
|
4359
|
+
sessionId: EventSessionManager.getSessionId(),
|
|
4360
|
+
device: EventDeviceCollector.getDeviceInfo(),
|
|
4361
|
+
user: EventUserCollector.getUserInfo(),
|
|
4362
|
+
params: properties || {}
|
|
4363
|
+
};
|
|
4364
|
+
await this.sendEventToServer(eventData);
|
|
4365
|
+
if (this._config.debug) {
|
|
4366
|
+
console.log('✅ Event tracked:', eventName, properties);
|
|
4367
|
+
}
|
|
4368
|
+
}
|
|
4369
|
+
catch (error) {
|
|
4370
|
+
console.error('❌ Failed to track event:', error);
|
|
4371
|
+
if (this._config.debug) {
|
|
4372
|
+
console.error('Event data:', { eventName, properties });
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
/**
|
|
4377
|
+
* 페이지 뷰 이벤트 (track의 편의 메소드)
|
|
4378
|
+
*/
|
|
4379
|
+
async pageView(pageData) {
|
|
4380
|
+
const properties = {};
|
|
4381
|
+
if (pageData?.page)
|
|
4382
|
+
properties.page = pageData.page;
|
|
4383
|
+
if (pageData?.title)
|
|
4384
|
+
properties.title = pageData.title;
|
|
4385
|
+
if (pageData?.category)
|
|
4386
|
+
properties.category = pageData.category;
|
|
4387
|
+
// pageData의 다른 속성들도 포함
|
|
4388
|
+
if (pageData) {
|
|
4389
|
+
Object.keys(pageData).forEach(key => {
|
|
4390
|
+
if (key !== 'page' && key !== 'title' && key !== 'category') {
|
|
4391
|
+
properties[key] = pageData[key];
|
|
4392
|
+
}
|
|
4393
|
+
});
|
|
4394
|
+
}
|
|
4395
|
+
// 현재 페이지 정보 자동 수집
|
|
4396
|
+
if (typeof window !== 'undefined') {
|
|
4397
|
+
if (!properties.page)
|
|
4398
|
+
properties.page = window.location.pathname;
|
|
4399
|
+
if (!properties.title)
|
|
4400
|
+
properties.title = document.title;
|
|
4401
|
+
properties.url = window.location.href;
|
|
4402
|
+
properties.referrer = document.referrer;
|
|
4403
|
+
}
|
|
4404
|
+
await this.track('page_view', properties);
|
|
4405
|
+
}
|
|
4406
|
+
/**
|
|
4407
|
+
* 서버에 이벤트 전송
|
|
4408
|
+
*/
|
|
4409
|
+
async sendEventToServer(eventData) {
|
|
4410
|
+
const response = await fetch(endpoints.events.track(), {
|
|
4411
|
+
method: 'POST',
|
|
4412
|
+
headers: {
|
|
4413
|
+
...ApiHeaders.create(this._config.apiKey),
|
|
4414
|
+
'Content-Type': 'application/json'
|
|
4415
|
+
},
|
|
4416
|
+
body: JSON.stringify(eventData)
|
|
4417
|
+
});
|
|
4418
|
+
if (!response.ok) {
|
|
4419
|
+
throw new Error(`Event tracking failed: ${response.status} ${response.statusText}`);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/**
|
|
4425
|
+
* AdStage SDK - 메인 네임스페이스 클래스
|
|
4426
|
+
* v2.0.0 - 확장 가능한 모듈 아키텍처
|
|
4427
|
+
*/
|
|
4428
|
+
class AdStage {
|
|
4429
|
+
constructor() {
|
|
4430
|
+
this._isInitialized = false;
|
|
4431
|
+
this._config = null;
|
|
4432
|
+
// 모듈 초기화 (ads, config는 완전 구현, events는 기본 구조)
|
|
4433
|
+
this.config = new ConfigModule();
|
|
4434
|
+
this.ads = new AdsModule();
|
|
4435
|
+
this.events = new EventsModule();
|
|
4436
|
+
}
|
|
4437
|
+
/**
|
|
4438
|
+
* AdStage SDK 초기화 (동기)
|
|
4439
|
+
*/
|
|
4440
|
+
static init(config) {
|
|
4441
|
+
if (!AdStage.instance) {
|
|
4442
|
+
AdStage.instance = new AdStage();
|
|
4443
|
+
}
|
|
4444
|
+
const instance = AdStage.instance;
|
|
4445
|
+
// 설정 검증
|
|
4446
|
+
if (!config.apiKey) {
|
|
4447
|
+
throw new Error('API key is required for AdStage initialization');
|
|
4448
|
+
}
|
|
4449
|
+
// 설정 저장 (서버 검증 없음)
|
|
4450
|
+
instance._config = {
|
|
4451
|
+
timeout: 30000,
|
|
4452
|
+
debug: false,
|
|
4453
|
+
modules: ['ads', 'events', 'config'],
|
|
4454
|
+
...config
|
|
4455
|
+
};
|
|
4456
|
+
// 🔧 baseUrl이 설정된 경우 전역 endpoints 객체 업데이트
|
|
4457
|
+
if (instance._config.baseUrl) {
|
|
4458
|
+
endpoints.setBaseUrl(instance._config.baseUrl);
|
|
4459
|
+
if (config.debug) {
|
|
4460
|
+
console.log('🌐 API endpoint configured:', instance._config.baseUrl);
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
// 모듈 동기 초기화
|
|
4464
|
+
const enabledModules = instance._config.modules || ['ads', 'events', 'config'];
|
|
4465
|
+
for (const moduleName of enabledModules) {
|
|
4466
|
+
const module = instance[moduleName];
|
|
4467
|
+
if (module && typeof module.init === 'function') {
|
|
4468
|
+
module.init(instance._config);
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
instance._isInitialized = true;
|
|
4472
|
+
if (config.debug) {
|
|
4473
|
+
console.log('🚀 AdStage SDK initialized (sync mode)', {
|
|
4474
|
+
version: '2.0.0',
|
|
4475
|
+
modules: enabledModules,
|
|
4476
|
+
apiKey: config.apiKey.substring(0, 8) + '...',
|
|
4477
|
+
mode: 'development'
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
/**
|
|
4482
|
+
* SDK 초기화 상태 확인
|
|
4483
|
+
*/
|
|
4484
|
+
static isReady() {
|
|
4485
|
+
return AdStage.instance?._isInitialized || false;
|
|
4486
|
+
}
|
|
4487
|
+
/**
|
|
4488
|
+
* 현재 설정 반환
|
|
4489
|
+
*/
|
|
4490
|
+
static getConfig() {
|
|
4491
|
+
return AdStage.instance?._config || null;
|
|
4492
|
+
}
|
|
4493
|
+
/**
|
|
4494
|
+
* SDK 인스턴스 반환 (공개 메소드로 변경)
|
|
4495
|
+
*/
|
|
4496
|
+
static getInstance() {
|
|
4497
|
+
if (!AdStage.instance) {
|
|
4498
|
+
throw new Error('AdStage not initialized. Call AdStage.init() first.');
|
|
4499
|
+
}
|
|
4500
|
+
return AdStage.instance;
|
|
4501
|
+
}
|
|
4502
|
+
/**
|
|
4503
|
+
* 편의성을 위한 정적 모듈 접근자들
|
|
4504
|
+
*/
|
|
4505
|
+
static get ads() {
|
|
4506
|
+
return AdStage.getInstance().ads;
|
|
4507
|
+
}
|
|
4508
|
+
static get events() {
|
|
4509
|
+
return AdStage.getInstance().events;
|
|
4510
|
+
}
|
|
4511
|
+
static get config() {
|
|
4512
|
+
return AdStage.getInstance().config;
|
|
4513
|
+
}
|
|
4514
|
+
/**
|
|
4515
|
+
* SDK 리셋 (테스트용)
|
|
4516
|
+
*/
|
|
4517
|
+
static reset() {
|
|
4518
|
+
if (AdStage.instance) {
|
|
4519
|
+
AdStage.instance._isInitialized = false;
|
|
4520
|
+
AdStage.instance._config = null;
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
/**
|
|
4525
|
+
* 디버그용 메서드들
|
|
4526
|
+
*/
|
|
4527
|
+
AdStage.debug = {
|
|
4528
|
+
/**
|
|
4529
|
+
* 모든 viewable 추적 데이터 초기화
|
|
4530
|
+
*/
|
|
4531
|
+
clearAllViewable: () => {
|
|
4532
|
+
ViewableEventTracker.clear();
|
|
4533
|
+
console.log('✅ AdStage Debug: 모든 viewable 추적 데이터 초기화됨');
|
|
4534
|
+
},
|
|
4535
|
+
/**
|
|
4536
|
+
* 특정 광고의 viewable 추적 초기화
|
|
4537
|
+
*/
|
|
4538
|
+
clearAdViewable: (adId, slotId) => {
|
|
4539
|
+
ViewableEventTracker.clearAdViewable(adId, slotId);
|
|
4540
|
+
console.log(`✅ AdStage Debug: 광고 ${adId}(${slotId})의 viewable 추적 초기화됨`);
|
|
4541
|
+
},
|
|
4542
|
+
/**
|
|
4543
|
+
* 현재 추적 중인 viewable 상태 확인
|
|
4544
|
+
*/
|
|
4545
|
+
getViewableStatus: () => {
|
|
4546
|
+
console.log('📊 AdStage Debug: 현재 viewable 추적 상태', {
|
|
4547
|
+
trackedCount: ViewableEventTracker.viewableTracker.size,
|
|
4548
|
+
trackedItems: Array.from(ViewableEventTracker.viewableTracker)
|
|
4549
|
+
});
|
|
4550
|
+
}
|
|
4551
|
+
};
|
|
4552
|
+
|
|
4553
|
+
/**
|
|
4554
|
+
* AdStage SDK - 전역 이벤트 함수들
|
|
4555
|
+
* Firebase Analytics와 유사한 간단한 API 제공
|
|
4556
|
+
*/
|
|
4557
|
+
/**
|
|
4558
|
+
* 이벤트 추적 (메인 함수)
|
|
4559
|
+
* @param eventName 이벤트 이름
|
|
4560
|
+
* @param properties 이벤트 속성
|
|
4561
|
+
*
|
|
4562
|
+
* @example
|
|
4563
|
+
* track('purchase', {
|
|
4564
|
+
* transaction_id: 'T123',
|
|
4565
|
+
* value: 99.99,
|
|
4566
|
+
* currency: 'USD'
|
|
4567
|
+
* });
|
|
4568
|
+
*/
|
|
4569
|
+
function track(eventName, properties) {
|
|
4570
|
+
if (!AdStage.isReady()) {
|
|
4571
|
+
console.warn('AdStage not initialized. Call AdStage.init() first.');
|
|
4572
|
+
return Promise.resolve();
|
|
4573
|
+
}
|
|
4574
|
+
return AdStage.events.track(eventName, properties);
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* 페이지 뷰 추적
|
|
4578
|
+
* @param pageData 페이지 정보
|
|
4579
|
+
*
|
|
4580
|
+
* @example
|
|
4581
|
+
* pageView({ page: '/products', title: 'Products Page' });
|
|
4582
|
+
*/
|
|
4583
|
+
function pageView(pageData) {
|
|
4584
|
+
if (!AdStage.isReady()) {
|
|
4585
|
+
console.warn('AdStage not initialized. Call AdStage.init() first.');
|
|
4586
|
+
return Promise.resolve();
|
|
4587
|
+
}
|
|
4588
|
+
return AdStage.events.pageView(pageData);
|
|
4589
|
+
}
|
|
4590
|
+
/**
|
|
4591
|
+
* 사용자 ID 설정
|
|
4592
|
+
* @param userId 사용자 ID
|
|
4593
|
+
*
|
|
4594
|
+
* @example
|
|
4595
|
+
* setUserId('user123');
|
|
4596
|
+
*/
|
|
4597
|
+
function setUserId(userId) {
|
|
4598
|
+
if (!AdStage.isReady()) {
|
|
4599
|
+
console.warn('AdStage not initialized. Call AdStage.init() first.');
|
|
4600
|
+
return;
|
|
4601
|
+
}
|
|
4602
|
+
AdStage.events.setUserId(userId);
|
|
4603
|
+
}
|
|
4604
|
+
/**
|
|
4605
|
+
* 현재 사용자 ID 반환
|
|
4606
|
+
*/
|
|
4607
|
+
function getUserId() {
|
|
4608
|
+
if (!AdStage.isReady()) {
|
|
4609
|
+
console.warn('AdStage not initialized. Call AdStage.init() first.');
|
|
4610
|
+
return undefined;
|
|
4611
|
+
}
|
|
4612
|
+
return AdStage.events.getUserId();
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
var jsxRuntime = {exports: {}};
|
|
4616
|
+
|
|
4617
|
+
var reactJsxRuntime_production_min = {};
|
|
4618
|
+
|
|
4619
|
+
var react = {exports: {}};
|
|
4620
|
+
|
|
4621
|
+
var react_production_min = {};
|
|
4622
|
+
|
|
4623
|
+
/**
|
|
4624
|
+
* @license React
|
|
4625
|
+
* react.production.min.js
|
|
4626
|
+
*
|
|
4627
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
4628
|
+
*
|
|
4629
|
+
* This source code is licensed under the MIT license found in the
|
|
4630
|
+
* LICENSE file in the root directory of this source tree.
|
|
4631
|
+
*/
|
|
4632
|
+
|
|
4633
|
+
var hasRequiredReact_production_min;
|
|
4634
|
+
|
|
4635
|
+
function requireReact_production_min () {
|
|
4636
|
+
if (hasRequiredReact_production_min) return react_production_min;
|
|
4637
|
+
hasRequiredReact_production_min = 1;
|
|
4638
|
+
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}
|
|
4639
|
+
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={};
|
|
4640
|
+
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;
|
|
4641
|
+
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};
|
|
4642
|
+
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}}
|
|
4643
|
+
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)}
|
|
4644
|
+
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=
|
|
4645
|
+
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}
|
|
4646
|
+
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;}
|
|
4647
|
+
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.");}
|
|
4648
|
+
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;
|
|
4649
|
+
react_production_min.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED=W;react_production_min.act=X;
|
|
4650
|
+
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);
|
|
4651
|
+
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}};
|
|
4652
|
+
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)};
|
|
4653
|
+
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)};
|
|
4654
|
+
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";
|
|
4655
|
+
return react_production_min;
|
|
4656
|
+
}
|
|
4657
|
+
|
|
4658
|
+
{
|
|
4659
|
+
react.exports = requireReact_production_min();
|
|
4660
|
+
}
|
|
4661
|
+
|
|
4662
|
+
var reactExports = react.exports;
|
|
4663
|
+
|
|
4664
|
+
/**
|
|
4665
|
+
* @license React
|
|
4666
|
+
* react-jsx-runtime.production.min.js
|
|
4667
|
+
*
|
|
4668
|
+
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
4669
|
+
*
|
|
4670
|
+
* This source code is licensed under the MIT license found in the
|
|
4671
|
+
* LICENSE file in the root directory of this source tree.
|
|
4672
|
+
*/
|
|
4673
|
+
|
|
4674
|
+
var hasRequiredReactJsxRuntime_production_min;
|
|
4675
|
+
|
|
4676
|
+
function requireReactJsxRuntime_production_min () {
|
|
4677
|
+
if (hasRequiredReactJsxRuntime_production_min) return reactJsxRuntime_production_min;
|
|
4678
|
+
hasRequiredReactJsxRuntime_production_min = 1;
|
|
4679
|
+
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};
|
|
4680
|
+
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;
|
|
4681
|
+
return reactJsxRuntime_production_min;
|
|
4682
|
+
}
|
|
4683
|
+
|
|
4684
|
+
{
|
|
4685
|
+
jsxRuntime.exports = requireReactJsxRuntime_production_min();
|
|
4686
|
+
}
|
|
4687
|
+
|
|
4688
|
+
var jsxRuntimeExports = jsxRuntime.exports;
|
|
4689
|
+
|
|
4690
|
+
const AdStageContext = reactExports.createContext(null);
|
|
4691
|
+
function AdStageProvider({ children, config }) {
|
|
4692
|
+
const [isInitialized, setIsInitialized] = reactExports.useState(false);
|
|
4693
|
+
const [currentConfig, setCurrentConfig] = reactExports.useState(null);
|
|
4694
|
+
const [error, setError] = reactExports.useState(null);
|
|
4695
|
+
const initialize = (newConfig) => {
|
|
4696
|
+
try {
|
|
4697
|
+
setError(null);
|
|
4698
|
+
// 기존 인스턴스가 있으면 리셋
|
|
4699
|
+
if (isInitialized) {
|
|
4700
|
+
AdStage.reset();
|
|
4701
|
+
}
|
|
4702
|
+
AdStage.init(newConfig);
|
|
4703
|
+
setCurrentConfig(newConfig);
|
|
4704
|
+
setIsInitialized(true);
|
|
4705
|
+
if (newConfig.debug) {
|
|
4706
|
+
console.log('✅ AdStage SDK initialized successfully via React Provider');
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
catch (err) {
|
|
4710
|
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
4711
|
+
setError(errorMessage);
|
|
4712
|
+
console.error('❌ AdStage SDK initialization failed:', err);
|
|
4713
|
+
setIsInitialized(false);
|
|
4714
|
+
setCurrentConfig(null);
|
|
4715
|
+
}
|
|
4716
|
+
};
|
|
4717
|
+
const reset = () => {
|
|
4718
|
+
try {
|
|
4719
|
+
AdStage.reset();
|
|
4720
|
+
setIsInitialized(false);
|
|
4721
|
+
setCurrentConfig(null);
|
|
4722
|
+
setError(null);
|
|
4723
|
+
}
|
|
4724
|
+
catch (err) {
|
|
4725
|
+
console.error('❌ AdStage SDK reset failed:', err);
|
|
4726
|
+
}
|
|
4727
|
+
};
|
|
4728
|
+
// 자동 초기화
|
|
4729
|
+
reactExports.useEffect(() => {
|
|
4730
|
+
if (config && !isInitialized) {
|
|
4731
|
+
initialize(config);
|
|
4732
|
+
}
|
|
4733
|
+
}, [config, isInitialized]);
|
|
4734
|
+
const contextValue = {
|
|
4735
|
+
isInitialized,
|
|
4736
|
+
config: currentConfig,
|
|
4737
|
+
initialize,
|
|
4738
|
+
reset,
|
|
4739
|
+
error
|
|
4740
|
+
};
|
|
4741
|
+
return (jsxRuntimeExports.jsx(AdStageContext.Provider, { value: contextValue, children: children }));
|
|
4742
|
+
}
|
|
4743
|
+
/**
|
|
4744
|
+
* AdStage Context Hook
|
|
4745
|
+
* AdStageProvider 내에서 SDK 상태에 접근할 수 있습니다.
|
|
4746
|
+
*/
|
|
4747
|
+
function useAdStageContext() {
|
|
4748
|
+
const context = reactExports.useContext(AdStageContext);
|
|
4749
|
+
if (!context) {
|
|
4750
|
+
throw new Error('useAdStageContext must be used within an AdStageProvider');
|
|
4751
|
+
}
|
|
4752
|
+
return context;
|
|
4753
|
+
}
|
|
4754
|
+
/**
|
|
4755
|
+
* AdStage Instance Hook
|
|
4756
|
+
* 초기화된 AdStage 인스턴스에 직접 접근할 수 있습니다.
|
|
4757
|
+
*/
|
|
4758
|
+
function useAdStageInstance() {
|
|
4759
|
+
const { isInitialized } = useAdStageContext();
|
|
4760
|
+
if (!isInitialized) {
|
|
4761
|
+
console.warn('AdStage SDK is not initialized. Please call initialize() first or provide config to AdStageProvider.');
|
|
4762
|
+
return null;
|
|
4763
|
+
}
|
|
4764
|
+
return AdStage;
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
/**
|
|
4768
|
+
* AdStage Web SDK
|
|
4769
|
+
* 네임스페이스 아키텍처 기반 SDK
|
|
4770
|
+
*/
|
|
4771
|
+
// 메인 네임스페이스 클래스
|
|
4772
|
+
// 버전 정보
|
|
4773
|
+
const SDK_VERSION = '2.0.0';
|
|
4774
|
+
const SUPPORTED_MODULES = ['ads', 'events', 'config'];
|
|
4775
|
+
// 브라우저 환경에서 전역 객체로 노출 (디버깅용)
|
|
4776
|
+
if (typeof window !== 'undefined') {
|
|
4777
|
+
window.AdStage = AdStage;
|
|
4778
|
+
}
|
|
4779
|
+
|
|
4780
|
+
exports.AdStage = AdStage;
|
|
4781
|
+
exports.AdStageProvider = AdStageProvider;
|
|
4782
|
+
exports.SDK_VERSION = SDK_VERSION;
|
|
4783
|
+
exports.SUPPORTED_MODULES = SUPPORTED_MODULES;
|
|
4784
|
+
exports.getUserId = getUserId;
|
|
4785
|
+
exports.pageView = pageView;
|
|
4786
|
+
exports.setUserId = setUserId;
|
|
4787
|
+
exports.track = track;
|
|
4788
|
+
exports.useAdStageContext = useAdStageContext;
|
|
4789
|
+
exports.useAdStageInstance = useAdStageInstance;
|
|
4790
|
+
|
|
4791
|
+
}));
|