@adstage/web-sdk 2.3.7 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs.js +1885 -2001
- package/dist/index.d.ts +12 -71
- package/dist/index.esm.js +1886 -1999
- package/dist/index.standalone.js +2212 -2397
- package/package.json +1 -1
- package/src/core/AdStage.ts +0 -9
- package/src/index.ts +0 -3
- package/src/managers/ads/advertisement-event-tracker.ts +8 -15
- package/src/managers/ads/carousel-slider-manager.ts +4 -35
- package/src/managers/ads/text-transition-manager.ts +1 -2
- package/src/managers/ads/viewable-event-tracker.ts +1 -5
- package/src/modules/ads/AdRenderer.ts +730 -0
- package/src/modules/ads/AdsModule.ts +27 -737
- package/src/react/AdStageProvider.tsx +0 -117
- package/src/react/index.ts +0 -11
- package/src/renderers/base-renderer.ts +5 -11
- package/src/types/advertisement.ts +17 -4
- package/src/types/api.ts +5 -1
package/dist/index.cjs.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var jsxRuntime = require('react/jsx-runtime');
|
|
4
|
-
var react = require('react');
|
|
5
|
-
|
|
6
3
|
// 광고 타입 정의
|
|
7
4
|
var AdType;
|
|
8
5
|
(function (AdType) {
|
|
@@ -22,8 +19,15 @@ var Platform;
|
|
|
22
19
|
// 광고 이벤트 타입
|
|
23
20
|
var AdEventType;
|
|
24
21
|
(function (AdEventType) {
|
|
25
|
-
AdEventType["
|
|
22
|
+
AdEventType["IMPRESSION"] = "IMPRESSION";
|
|
26
23
|
AdEventType["CLICK"] = "CLICK";
|
|
24
|
+
AdEventType["HOVER"] = "HOVER";
|
|
25
|
+
AdEventType["VIEWABLE"] = "VIEWABLE";
|
|
26
|
+
AdEventType["VIEWABLE_IMPRESSION"] = "VIEWABLE_IMPRESSION";
|
|
27
|
+
AdEventType["COMPLETED"] = "COMPLETED";
|
|
28
|
+
AdEventType["VIDEO_START"] = "VIDEO_START";
|
|
29
|
+
AdEventType["VIDEO_COMPLETE"] = "VIDEO_COMPLETE";
|
|
30
|
+
AdEventType["ERROR"] = "ERROR";
|
|
27
31
|
})(AdEventType || (AdEventType = {}));
|
|
28
32
|
// 디바이스 타입
|
|
29
33
|
var DeviceType;
|
|
@@ -33,6 +37,86 @@ var DeviceType;
|
|
|
33
37
|
DeviceType["TABLET"] = "TABLET";
|
|
34
38
|
})(DeviceType || (DeviceType = {}));
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* VIEWABLE 이벤트 추적 및 중복 방지 관리 클래스
|
|
42
|
+
* - 메모리 기반 중복 확인
|
|
43
|
+
* - 세션 스토리지 기반 영구 추적
|
|
44
|
+
* - 자동 정리 기능
|
|
45
|
+
* - ViewabilityTracker와 함께 사용하여 중복 viewable 이벤트 방지
|
|
46
|
+
*/
|
|
47
|
+
class ViewableEventTracker {
|
|
48
|
+
/**
|
|
49
|
+
* 중복 viewable 이벤트 여부 확인
|
|
50
|
+
*/
|
|
51
|
+
static isDuplicateViewable(adId, slotId, debug = false) {
|
|
52
|
+
const key = `${adId}_${slotId}`;
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
// 메모리 기반 중복 확인 (새로고침 시 초기화됨)
|
|
55
|
+
const lastViewable = ViewableEventTracker.viewableTracker.get(key);
|
|
56
|
+
if (lastViewable && (now - lastViewable) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
|
|
57
|
+
if (debug) {
|
|
58
|
+
console.log(`Duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - lastViewable)) / 1000)}s remaining`);
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
// 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
|
|
63
|
+
const sessionKey = `adstage_viewable_${key}`;
|
|
64
|
+
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
65
|
+
if (sessionViewable) {
|
|
66
|
+
const sessionTime = parseInt(sessionViewable, 10);
|
|
67
|
+
if (!isNaN(sessionTime) && (now - sessionTime) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
|
|
68
|
+
if (debug) {
|
|
69
|
+
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
|
|
70
|
+
}
|
|
71
|
+
// 메모리에도 기록하여 이후 요청 최적화
|
|
72
|
+
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// viewable 이벤트 시점 기록 (메모리 + 세션 스토리지)
|
|
77
|
+
ViewableEventTracker.viewableTracker.set(key, now);
|
|
78
|
+
sessionStorage.setItem(sessionKey, now.toString());
|
|
79
|
+
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
80
|
+
ViewableEventTracker.cleanupOldViewables();
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 오래된 viewable 추적 데이터 정리
|
|
85
|
+
*/
|
|
86
|
+
static cleanupOldViewables() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
|
|
89
|
+
// 세션 스토리지 정리
|
|
90
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
91
|
+
const key = sessionStorage.key(i);
|
|
92
|
+
if (key && key.startsWith('adstage_viewable_')) {
|
|
93
|
+
const timestamp = sessionStorage.getItem(key);
|
|
94
|
+
if (timestamp) {
|
|
95
|
+
const time = parseInt(timestamp, 10);
|
|
96
|
+
if (!isNaN(time) && (now - time) > cleanupThreshold) {
|
|
97
|
+
sessionStorage.removeItem(key);
|
|
98
|
+
i--; // 인덱스 조정
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 메모리 정리
|
|
104
|
+
for (const [key, timestamp] of ViewableEventTracker.viewableTracker.entries()) {
|
|
105
|
+
if ((now - timestamp) > cleanupThreshold) {
|
|
106
|
+
ViewableEventTracker.viewableTracker.delete(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* 모든 추적 데이터 정리
|
|
112
|
+
*/
|
|
113
|
+
static clear() {
|
|
114
|
+
ViewableEventTracker.viewableTracker.clear();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
ViewableEventTracker.viewableTracker = new Map();
|
|
118
|
+
ViewableEventTracker.VIEWABLE_COOLDOWN = 300000; // 5분 쿨다운
|
|
119
|
+
|
|
36
120
|
/**
|
|
37
121
|
* SSR 안전한 DOM API 래퍼 클래스
|
|
38
122
|
* 서버사이드 렌더링 환경에서 DOM API 접근 시 오류를 방지합니다.
|
|
@@ -320,453 +404,1050 @@ class DOMUtils {
|
|
|
320
404
|
}
|
|
321
405
|
|
|
322
406
|
/**
|
|
323
|
-
*
|
|
407
|
+
* 디바이스 정보 수집 클래스
|
|
408
|
+
* - 브라우저 환경 정보 수집
|
|
409
|
+
* - 디바이스 ID 생성 및 관리
|
|
410
|
+
* - 세션 ID 생성 및 관리
|
|
324
411
|
*/
|
|
325
|
-
class
|
|
326
|
-
constructor(trackEvent) {
|
|
327
|
-
this.trackEvent = trackEvent;
|
|
328
|
-
}
|
|
412
|
+
class DeviceInfoCollector {
|
|
329
413
|
/**
|
|
330
|
-
*
|
|
414
|
+
* 디바이스 ID 생성 및 반환 (SSR 안전)
|
|
331
415
|
*/
|
|
332
|
-
|
|
333
|
-
DOMUtils.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
416
|
+
static generateDeviceId() {
|
|
417
|
+
if (!DOMUtils.isBrowser())
|
|
418
|
+
return 'ssr_device_' + Date.now();
|
|
419
|
+
const stored = localStorage.getItem('adstage_device_id');
|
|
420
|
+
if (stored)
|
|
421
|
+
return stored;
|
|
422
|
+
const deviceId = 'device_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
|
|
423
|
+
localStorage.setItem('adstage_device_id', deviceId);
|
|
424
|
+
return deviceId;
|
|
339
425
|
}
|
|
340
426
|
/**
|
|
341
|
-
*
|
|
427
|
+
* 세션 ID 생성 및 반환 (SSR 안전)
|
|
342
428
|
*/
|
|
343
|
-
|
|
344
|
-
DOMUtils.
|
|
429
|
+
static generateSessionId() {
|
|
430
|
+
if (!DOMUtils.isBrowser())
|
|
431
|
+
return 'ssr_session_' + Date.now();
|
|
432
|
+
const stored = sessionStorage.getItem('adstage_session_id');
|
|
433
|
+
if (stored)
|
|
434
|
+
return stored;
|
|
435
|
+
const sessionId = 'session_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
|
|
436
|
+
sessionStorage.setItem('adstage_session_id', sessionId);
|
|
437
|
+
return sessionId;
|
|
345
438
|
}
|
|
346
439
|
/**
|
|
347
|
-
*
|
|
440
|
+
* 모바일 디바이스 여부 확인
|
|
348
441
|
*/
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return undefined;
|
|
352
|
-
if (typeof value === 'number') {
|
|
353
|
-
return value > 0 ? `${value}px` : undefined;
|
|
354
|
-
}
|
|
355
|
-
if (typeof value === 'string') {
|
|
356
|
-
const trimmed = value.trim();
|
|
357
|
-
if (!trimmed)
|
|
358
|
-
return undefined;
|
|
359
|
-
// 퍼센트 값 처리
|
|
360
|
-
if (trimmed.endsWith('%')) {
|
|
361
|
-
const percent = parseFloat(trimmed);
|
|
362
|
-
return !isNaN(percent) && percent > 0 ? trimmed : undefined;
|
|
363
|
-
}
|
|
364
|
-
// px 값 처리 (px 단위 포함/미포함 모두 지원)
|
|
365
|
-
const numValue = trimmed.endsWith('px')
|
|
366
|
-
? parseFloat(trimmed.slice(0, -2))
|
|
367
|
-
: parseFloat(trimmed);
|
|
368
|
-
return !isNaN(numValue) && numValue > 0 ? `${numValue}px` : undefined;
|
|
369
|
-
}
|
|
370
|
-
return undefined;
|
|
442
|
+
static isMobile() {
|
|
443
|
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|
371
444
|
}
|
|
372
445
|
/**
|
|
373
|
-
*
|
|
446
|
+
* 플랫폼 타입 반환 (서버 enum에 맞춤)
|
|
374
447
|
*/
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
overflow: 'hidden',
|
|
380
|
-
};
|
|
381
|
-
// 사용자가 지정한 크기가 있을 때만 적용
|
|
382
|
-
const parsedWidth = this.parseSizeValue(slot.width);
|
|
383
|
-
const parsedHeight = this.parseSizeValue(slot.height);
|
|
384
|
-
if (parsedWidth) {
|
|
385
|
-
styles.width = parsedWidth;
|
|
386
|
-
}
|
|
387
|
-
if (parsedHeight) {
|
|
388
|
-
styles.height = parsedHeight;
|
|
448
|
+
static getPlatform() {
|
|
449
|
+
const userAgent = navigator.userAgent.toLowerCase();
|
|
450
|
+
if (/iphone|ipad|ipod/.test(userAgent)) {
|
|
451
|
+
return 'ios';
|
|
389
452
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* 이미지 스타일 (고유 사이즈 유지, 사용자 지정 크기 우선)
|
|
394
|
-
*/
|
|
395
|
-
getImageStyles(slot) {
|
|
396
|
-
const styles = {
|
|
397
|
-
display: 'block',
|
|
398
|
-
'object-position': 'center', // 🎯 이미지 항상 중앙 정렬
|
|
399
|
-
};
|
|
400
|
-
// 사용자가 컨테이너 크기를 지정했는지 확인
|
|
401
|
-
const parsedWidth = this.parseSizeValue(slot?.width);
|
|
402
|
-
const parsedHeight = this.parseSizeValue(slot?.height);
|
|
403
|
-
const hasUserDefinedSize = parsedWidth || parsedHeight;
|
|
404
|
-
if (hasUserDefinedSize) {
|
|
405
|
-
// 🎯 사용자가 크기를 지정한 경우: 컨테이너에 꽉 차도록 설정
|
|
406
|
-
styles.width = '100%';
|
|
407
|
-
styles.height = '100%';
|
|
408
|
-
styles['object-fit'] = 'cover'; // 컨테이너에 꽉 찬 상태로 비율 유지
|
|
409
|
-
styles['object-position'] = 'center';
|
|
453
|
+
if (/android/.test(userAgent)) {
|
|
454
|
+
return 'android';
|
|
410
455
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
styles['max-width'] = '100%';
|
|
414
|
-
styles.height = 'auto';
|
|
456
|
+
if (DeviceInfoCollector.isMobile()) {
|
|
457
|
+
return 'web'; // 모바일 웹
|
|
415
458
|
}
|
|
416
|
-
return
|
|
459
|
+
return 'desktop'; // 데스크톱 웹
|
|
417
460
|
}
|
|
418
461
|
/**
|
|
419
|
-
*
|
|
462
|
+
* 완전한 디바이스 정보 수집
|
|
420
463
|
*/
|
|
421
|
-
|
|
464
|
+
static collectDeviceInfo() {
|
|
465
|
+
const viewportInfo = DOMUtils.getViewportInfo();
|
|
422
466
|
return {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
467
|
+
deviceId: DeviceInfoCollector.generateDeviceId(),
|
|
468
|
+
sessionId: DeviceInfoCollector.generateSessionId(),
|
|
469
|
+
osVersion: DOMUtils.isBrowser() ? navigator.platform : 'SSR',
|
|
470
|
+
deviceModel: DOMUtils.isBrowser() ? navigator.platform : 'SSR',
|
|
471
|
+
appVersion: '1.0.0',
|
|
472
|
+
sdkVersion: '1.0.0',
|
|
473
|
+
language: DOMUtils.isBrowser() ? (navigator.language || 'ko') : 'ko',
|
|
474
|
+
country: 'KR', // 기본값
|
|
475
|
+
ipAddress: '', // 서버에서 자동으로 설정됨
|
|
476
|
+
userAgent: DOMUtils.isBrowser() ? navigator.userAgent : 'SSR',
|
|
477
|
+
timezone: DOMUtils.isBrowser() ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'UTC',
|
|
478
|
+
viewportWidth: viewportInfo.width,
|
|
479
|
+
viewportHeight: viewportInfo.height,
|
|
480
|
+
screenWidth: DOMUtils.isBrowser() ? screen.width : 0,
|
|
481
|
+
screenHeight: DOMUtils.isBrowser() ? screen.height : 0,
|
|
482
|
+
colorDepth: DOMUtils.isBrowser() ? screen.colorDepth : 24,
|
|
483
|
+
pixelRatio: viewportInfo.pixelRatio,
|
|
484
|
+
connectionType: DOMUtils.isBrowser() ? (navigator.connection?.effectiveType || 'unknown') : 'unknown',
|
|
485
|
+
platform: DeviceInfoCollector.getPlatform(),
|
|
426
486
|
};
|
|
427
487
|
}
|
|
428
488
|
/**
|
|
429
|
-
*
|
|
430
|
-
*/
|
|
431
|
-
createImageElement(imageUrl, alt = '', slot) {
|
|
432
|
-
const img = DOMUtils.safeCreateElement('img');
|
|
433
|
-
if (!img)
|
|
434
|
-
return null;
|
|
435
|
-
img.src = imageUrl;
|
|
436
|
-
img.alt = alt;
|
|
437
|
-
this.applyStyles(img, this.getImageStyles(slot));
|
|
438
|
-
return img;
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* 텍스트 요소 생성 (SSR 안전)
|
|
489
|
+
* 슬롯 위치 정보 가져오기 (SSR 안전)
|
|
442
490
|
*/
|
|
443
|
-
|
|
444
|
-
const element = DOMUtils.
|
|
491
|
+
static getSlotPosition(containerId) {
|
|
492
|
+
const element = DOMUtils.safeGetElementById(containerId);
|
|
445
493
|
if (!element)
|
|
446
|
-
return
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
...additionalStyles,
|
|
451
|
-
});
|
|
452
|
-
return element;
|
|
494
|
+
return 'unknown';
|
|
495
|
+
const rect = element.getBoundingClientRect();
|
|
496
|
+
const scrollInfo = DOMUtils.getScrollInfo();
|
|
497
|
+
return `x:${Math.round(rect.left)},y:${Math.round(rect.top + scrollInfo.scrollTop)}`;
|
|
453
498
|
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* AdStage SDK - API 헤더 유틸리티
|
|
503
|
+
* 공통 헤더 생성 로직
|
|
504
|
+
*/
|
|
505
|
+
class ApiHeaders {
|
|
454
506
|
/**
|
|
455
|
-
*
|
|
507
|
+
* 표준 API 헤더 생성
|
|
456
508
|
*/
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (!placeholder) {
|
|
461
|
-
// SSR에서는 빈 div를 반환하되, 브라우저에서는 제대로 작동하도록 함
|
|
462
|
-
if (typeof document !== 'undefined') {
|
|
463
|
-
placeholder = document.createElement('div');
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
// SSR 환경에서는 더미 객체 반환 (타입 단언 사용)
|
|
467
|
-
placeholder = {};
|
|
468
|
-
}
|
|
509
|
+
static create(apiKey, options) {
|
|
510
|
+
if (!apiKey) {
|
|
511
|
+
throw new Error('API key is required');
|
|
469
512
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
'justify-content': 'center',
|
|
478
|
-
color: '#6c757d',
|
|
479
|
-
...this.getBaseFontStyles(),
|
|
480
|
-
// 플레이스홀더는 최소 크기 보장
|
|
481
|
-
'min-width': '100px',
|
|
482
|
-
'min-height': '100px',
|
|
483
|
-
});
|
|
484
|
-
DOMUtils.safeSetTextContent(placeholder, text);
|
|
513
|
+
const headers = {
|
|
514
|
+
'x-api-key': apiKey,
|
|
515
|
+
'Content-Type': options?.contentType || 'application/json'
|
|
516
|
+
};
|
|
517
|
+
// User-Agent는 이벤트 추적에서 실제로 사용됨
|
|
518
|
+
if (typeof navigator !== 'undefined') {
|
|
519
|
+
headers['User-Agent'] = options?.userAgent || navigator.userAgent;
|
|
485
520
|
}
|
|
486
|
-
|
|
521
|
+
// X-Current-URL은 현재 서버에서 사용하지 않으므로 제거
|
|
522
|
+
// 필요시 이벤트 데이터 body에 포함
|
|
523
|
+
return headers;
|
|
487
524
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
// SSR 환경에서는 기본 div 반환
|
|
498
|
-
return document.createElement('div');
|
|
499
|
-
}
|
|
500
|
-
// 기본 컨테이너 스타일 적용 (불필요한 스타일 제거)
|
|
501
|
-
this.applyStyles(adElement, this.getBaseContainerStyles(slot));
|
|
502
|
-
// 배너 광고는 이미지만 표시
|
|
503
|
-
if (!ad.imageUrl) {
|
|
504
|
-
// 이미지가 없는 경우 플레이스홀더 반환
|
|
505
|
-
const placeholder = this.createPlaceholder(slot, '배너 광고');
|
|
506
|
-
return placeholder || adElement;
|
|
507
|
-
}
|
|
508
|
-
const img = this.createImageElement(ad.imageUrl, '', slot);
|
|
509
|
-
if (img) {
|
|
510
|
-
DOMUtils.safeAppendChild(adElement, img);
|
|
511
|
-
// 클릭 이벤트 추가
|
|
512
|
-
this.addClickHandler(adElement, ad, slot);
|
|
525
|
+
/**
|
|
526
|
+
* 이벤트 추적용 헤더 생성
|
|
527
|
+
* User-Agent는 서버에서 실제로 사용됨
|
|
528
|
+
*/
|
|
529
|
+
static createForEvents(apiKey, eventData) {
|
|
530
|
+
const baseHeaders = ApiHeaders.create(apiKey);
|
|
531
|
+
// User-Agent 오버라이드 (서버에서 실제 사용)
|
|
532
|
+
if (eventData?.userAgent) {
|
|
533
|
+
baseHeaders['User-Agent'] = eventData.userAgent;
|
|
513
534
|
}
|
|
514
|
-
|
|
535
|
+
// 다른 정보들은 HTTP 헤더가 아닌 이벤트 데이터 body에 포함하는 것이 적절
|
|
536
|
+
// (currentUrl, referrer 등은 POST body로 전송)
|
|
537
|
+
return baseHeaders;
|
|
515
538
|
}
|
|
516
539
|
}
|
|
517
540
|
|
|
518
541
|
/**
|
|
519
|
-
*
|
|
542
|
+
* 광고 이벤트 추적 관리 클래스
|
|
543
|
+
* - 광고 전용 이벤트 추적 및 전송
|
|
544
|
+
* - Viewable 이벤트 중복 방지 통합
|
|
545
|
+
* - 광고 서버 API 통신
|
|
520
546
|
*/
|
|
521
|
-
class
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
};
|
|
538
|
-
// 사용자가 크기를 지정하지 않은 경우 컨텐츠에 맞춤
|
|
539
|
-
if (!slot.width || slot.width === 0) {
|
|
540
|
-
containerStyles.display = 'inline-flex';
|
|
541
|
-
containerStyles['white-space'] = 'nowrap';
|
|
542
|
-
containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
|
|
543
|
-
}
|
|
544
|
-
// height만 자동인 경우 줄바꿈 허용
|
|
545
|
-
if ((slot.width && slot.width !== 0) && (!slot.height || slot.height === 0)) {
|
|
546
|
-
containerStyles['white-space'] = 'normal';
|
|
547
|
-
containerStyles['min-height'] = 'auto';
|
|
548
|
-
containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
|
|
549
|
-
}
|
|
550
|
-
this.applyStyles(adElement, containerStyles);
|
|
551
|
-
// 텍스트 광고는 textContent만 표시
|
|
552
|
-
if (!ad.textContent) {
|
|
553
|
-
// 텍스트가 없는 경우 플레이스홀더 반환
|
|
554
|
-
return this.createPlaceholder(slot, '텍스트 광고');
|
|
555
|
-
}
|
|
556
|
-
// 텍스트 콘텐츠 생성
|
|
557
|
-
const textContent = this.createTextElement(ad.textContent, 'div', {
|
|
558
|
-
'font-size': '16px',
|
|
559
|
-
'font-weight': '500',
|
|
560
|
-
color: '#212529',
|
|
561
|
-
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|
|
562
|
-
});
|
|
563
|
-
if (textContent) {
|
|
564
|
-
adElement.appendChild(textContent);
|
|
565
|
-
}
|
|
566
|
-
// 사용자가 text-align을 지정했는지 확인하고 레이아웃 조정
|
|
567
|
-
setTimeout(() => {
|
|
568
|
-
if (!adElement || typeof window === 'undefined')
|
|
569
|
-
return;
|
|
570
|
-
const computedStyle = window.getComputedStyle(adElement);
|
|
571
|
-
const textAlign = computedStyle.textAlign;
|
|
572
|
-
// 사용자가 text-align을 설정했고, width가 없는 경우
|
|
573
|
-
if (textAlign && textAlign !== 'start' && textAlign !== 'left' && (!slot.width || slot.width === 0)) {
|
|
574
|
-
// 블록 레벨로 변경하여 text-align이 제대로 작동하도록 함
|
|
575
|
-
adElement.style.display = 'block';
|
|
576
|
-
adElement.style.whiteSpace = 'normal';
|
|
577
|
-
// 최소 너비 설정 (텍스트가 한 줄일 때를 위해)
|
|
578
|
-
if (textContent) {
|
|
579
|
-
const textRect = textContent.getBoundingClientRect();
|
|
580
|
-
if (textRect.width > 0) {
|
|
581
|
-
adElement.style.minWidth = `${textRect.width}px`;
|
|
582
|
-
}
|
|
547
|
+
class AdvertisementEventTracker {
|
|
548
|
+
constructor(baseUrl, apiKey, debug, slots) {
|
|
549
|
+
this.baseUrl = baseUrl;
|
|
550
|
+
this.apiKey = apiKey;
|
|
551
|
+
this.debug = debug;
|
|
552
|
+
this.slots = slots;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* 광고 이벤트 추적 - viewability 데이터 지원
|
|
556
|
+
*/
|
|
557
|
+
async trackAdvertisementEvent(adId, slotId, eventType, additionalData) {
|
|
558
|
+
try {
|
|
559
|
+
// VIEWABLE 이벤트의 경우 중복 확인
|
|
560
|
+
if (eventType === AdEventType.VIEWABLE) {
|
|
561
|
+
if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
|
|
562
|
+
return; // 중복 viewable 이벤트이므로 추적하지 않음
|
|
583
563
|
}
|
|
584
564
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
565
|
+
// 현재 슬롯 정보 가져오기
|
|
566
|
+
const slot = this.slots.get(slotId);
|
|
567
|
+
// 디바이스 정보 수집
|
|
568
|
+
const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
|
|
569
|
+
// 광고 이벤트 데이터 구성 (DTO 구조에 맞춤)
|
|
570
|
+
const eventData = {
|
|
571
|
+
// 서버에서 자동 설정: orgId, advertisementId, action
|
|
572
|
+
// 하지만 DTO 검증을 위해 임시값 제공
|
|
573
|
+
orgId: 'temp', // 서버에서 API 키로부터 덮어씀
|
|
574
|
+
advertisementId: adId, // 서버에서 URL 파라미터로부터 덮어씀
|
|
575
|
+
action: eventType, // 서버에서 URL 파라미터로부터 덮어씀
|
|
576
|
+
// 필수 필드들
|
|
577
|
+
adType: slot?.adType || 'BANNER',
|
|
578
|
+
platform: deviceInfo.platform,
|
|
579
|
+
// 디바이스 정보는 deviceInfo 객체로 래핑
|
|
580
|
+
deviceInfo: deviceInfo,
|
|
581
|
+
// 페이지 및 슬롯 정보
|
|
582
|
+
pageUrl: DOMUtils.getPageInfo().url,
|
|
583
|
+
pageTitle: DOMUtils.getPageInfo().title,
|
|
584
|
+
referrer: DOMUtils.getPageInfo().referrer,
|
|
585
|
+
slotId,
|
|
586
|
+
slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
|
|
587
|
+
slotWidth: AdvertisementEventTracker.parseNumericValue(slot?.width),
|
|
588
|
+
slotHeight: AdvertisementEventTracker.parseNumericValue(slot?.height),
|
|
589
|
+
sessionId: deviceInfo.sessionId,
|
|
590
|
+
// 성능 메트릭
|
|
591
|
+
pageLoadTime: performance.now(),
|
|
592
|
+
// 추가 메타데이터
|
|
593
|
+
metadata: {
|
|
594
|
+
eventType,
|
|
595
|
+
sdkVersion: '1.0.0',
|
|
596
|
+
timestamp: Date.now(),
|
|
597
|
+
},
|
|
598
|
+
// viewable 관련 추가 데이터 (DTO 필드명과 매칭)
|
|
599
|
+
...(additionalData?.viewabilityMetrics && {
|
|
600
|
+
isViewable: additionalData.viewabilityMetrics.isViewable,
|
|
601
|
+
exposureTime: additionalData.viewabilityMetrics.exposureTime,
|
|
602
|
+
maxVisibilityRatio: additionalData.viewabilityMetrics.maxVisibilityRatio,
|
|
603
|
+
firstViewableTime: additionalData.viewabilityMetrics.firstViewableTime,
|
|
604
|
+
// IAB 표준 준수 여부
|
|
605
|
+
iabCompliant: additionalData.viewabilityMetrics.isViewable,
|
|
606
|
+
}),
|
|
607
|
+
// fraud 관련 데이터 (DTO 필드명과 매칭)
|
|
608
|
+
...(additionalData?.fraudScore !== undefined && {
|
|
609
|
+
fraudScore: additionalData.fraudScore,
|
|
610
|
+
fraudReasons: additionalData.fraudReasons,
|
|
611
|
+
riskLevel: additionalData.riskLevel,
|
|
612
|
+
}),
|
|
613
|
+
};
|
|
614
|
+
await fetch(`${this.baseUrl}/advertisements/events/${adId}/${eventType}`, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
headers: ApiHeaders.createForEvents(this.apiKey, eventData),
|
|
617
|
+
body: JSON.stringify(eventData),
|
|
618
|
+
});
|
|
619
|
+
if (this.debug) {
|
|
620
|
+
console.log(`Tracked advertisement event: ${eventType} for ad ${adId}`, eventData);
|
|
621
|
+
}
|
|
600
622
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// 네이티브 광고는 이미지만 표시
|
|
604
|
-
if (!ad.imageUrl) {
|
|
605
|
-
// 이미지가 없는 경우 플레이스홀더 반환
|
|
606
|
-
const placeholder = this.createPlaceholder(slot, '네이티브 광고');
|
|
607
|
-
return placeholder || adElement;
|
|
623
|
+
catch (error) {
|
|
624
|
+
console.error('Failed to track advertisement event:', error);
|
|
608
625
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* 크기 값을 숫자로 변환 (서버 API용)
|
|
629
|
+
*/
|
|
630
|
+
static parseNumericValue(value) {
|
|
631
|
+
if (typeof value === 'number') {
|
|
632
|
+
return value;
|
|
615
633
|
}
|
|
616
|
-
|
|
634
|
+
if (typeof value === 'string') {
|
|
635
|
+
// px 단위 제거하고 숫자만 추출
|
|
636
|
+
const numericValue = parseFloat(value.replace(/px$/, ''));
|
|
637
|
+
return isNaN(numericValue) ? 0 : numericValue;
|
|
638
|
+
}
|
|
639
|
+
return 0; // 기본값
|
|
617
640
|
}
|
|
618
641
|
}
|
|
619
642
|
|
|
620
643
|
/**
|
|
621
|
-
*
|
|
644
|
+
* IAB 표준 준수 viewable impression 측정
|
|
622
645
|
*/
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
+
// 광고 타입별 IAB 표준 설정
|
|
647
|
+
const VIEWABILITY_STANDARDS = {
|
|
648
|
+
BANNER: {
|
|
649
|
+
threshold: 0.5,
|
|
650
|
+
minDuration: 1000,
|
|
651
|
+
maxMeasureTime: 30000
|
|
652
|
+
},
|
|
653
|
+
VIDEO: {
|
|
654
|
+
threshold: 0.5,
|
|
655
|
+
minDuration: 2000,
|
|
656
|
+
maxMeasureTime: 60000
|
|
657
|
+
},
|
|
658
|
+
NATIVE: {
|
|
659
|
+
threshold: 0.5,
|
|
660
|
+
minDuration: 1000,
|
|
661
|
+
maxMeasureTime: 30000
|
|
662
|
+
},
|
|
663
|
+
INTERSTITIAL: {
|
|
664
|
+
threshold: 0.5,
|
|
665
|
+
minDuration: 1000,
|
|
666
|
+
maxMeasureTime: 10000
|
|
667
|
+
},
|
|
668
|
+
TEXT: {
|
|
669
|
+
threshold: 0.5,
|
|
670
|
+
minDuration: 1000,
|
|
671
|
+
maxMeasureTime: 30000
|
|
672
|
+
},
|
|
673
|
+
POPUP: {
|
|
674
|
+
threshold: 0.5,
|
|
675
|
+
minDuration: 1000,
|
|
676
|
+
maxMeasureTime: 10000
|
|
646
677
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
this.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
|
|
664
|
-
});
|
|
665
|
-
return video;
|
|
678
|
+
};
|
|
679
|
+
class ViewabilityTracker {
|
|
680
|
+
constructor(element, adType, onViewable) {
|
|
681
|
+
this.observer = null;
|
|
682
|
+
this.viewabilityTimer = null;
|
|
683
|
+
this.maxVisibilityTimer = null;
|
|
684
|
+
this.startTime = 0;
|
|
685
|
+
this.maxVisibilityRatio = 0;
|
|
686
|
+
this.firstViewableTime = null;
|
|
687
|
+
this.isViewableAchieved = false;
|
|
688
|
+
this.element = element;
|
|
689
|
+
this.config = VIEWABILITY_STANDARDS[adType] || VIEWABILITY_STANDARDS.BANNER;
|
|
690
|
+
this.onViewableCallback = onViewable;
|
|
691
|
+
this.startTime = performance.now();
|
|
692
|
+
this.initIntersectionObserver();
|
|
693
|
+
this.initMaxMeasureTimer();
|
|
666
694
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
class InterstitialAdRenderer extends BaseAdRenderer {
|
|
673
|
-
render(ad, slot) {
|
|
674
|
-
let adElement = DOMUtils.safeCreateElement('div');
|
|
675
|
-
if (!adElement) {
|
|
676
|
-
return this.createPlaceholder(slot, '전면 광고');
|
|
695
|
+
initIntersectionObserver() {
|
|
696
|
+
// IntersectionObserver 지원 확인
|
|
697
|
+
if (!('IntersectionObserver' in window)) {
|
|
698
|
+
console.warn('IntersectionObserver not supported, viewability tracking disabled');
|
|
699
|
+
return;
|
|
677
700
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
display: 'flex',
|
|
682
|
-
'flex-direction': 'column',
|
|
701
|
+
this.observer = new IntersectionObserver((entries) => this.handleIntersection(entries), {
|
|
702
|
+
threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0],
|
|
703
|
+
rootMargin: '0px'
|
|
683
704
|
});
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
705
|
+
this.observer.observe(this.element);
|
|
706
|
+
}
|
|
707
|
+
handleIntersection(entries) {
|
|
708
|
+
entries.forEach(entry => {
|
|
709
|
+
const visibilityRatio = entry.intersectionRatio;
|
|
710
|
+
const isVisible = this.isDocumentVisible();
|
|
711
|
+
// 최대 가시성 비율 추적
|
|
712
|
+
this.maxVisibilityRatio = Math.max(this.maxVisibilityRatio, visibilityRatio);
|
|
713
|
+
// Viewable 조건 확인 (50% 이상 + 문서 가시성)
|
|
714
|
+
if (visibilityRatio >= this.config.threshold && isVisible) {
|
|
715
|
+
this.startViewabilityTimer();
|
|
689
716
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// 이미지가 없고 비디오가 있는 경우
|
|
693
|
-
const video = this.createVideoElement(ad.videoUrl, ad, slot);
|
|
694
|
-
if (video) {
|
|
695
|
-
adElement.appendChild(video);
|
|
717
|
+
else {
|
|
718
|
+
this.stopViewabilityTimer();
|
|
696
719
|
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
isDocumentVisible() {
|
|
723
|
+
// 단순한 문서 가시성 확인
|
|
724
|
+
return !document.hidden && document.visibilityState === 'visible';
|
|
725
|
+
}
|
|
726
|
+
startViewabilityTimer() {
|
|
727
|
+
if (this.viewabilityTimer || this.isViewableAchieved)
|
|
728
|
+
return;
|
|
729
|
+
if (this.firstViewableTime === null) {
|
|
730
|
+
this.firstViewableTime = performance.now();
|
|
697
731
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
732
|
+
this.viewabilityTimer = setTimeout(() => {
|
|
733
|
+
this.onViewabilityAchieved();
|
|
734
|
+
}, this.config.minDuration);
|
|
735
|
+
}
|
|
736
|
+
stopViewabilityTimer() {
|
|
737
|
+
if (this.viewabilityTimer) {
|
|
738
|
+
clearTimeout(this.viewabilityTimer);
|
|
739
|
+
this.viewabilityTimer = null;
|
|
701
740
|
}
|
|
702
|
-
// 클릭 이벤트 추가
|
|
703
|
-
this.addClickHandler(adElement, ad, slot);
|
|
704
|
-
return adElement;
|
|
705
741
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
if (!video)
|
|
712
|
-
return null;
|
|
713
|
-
video.src = videoUrl;
|
|
714
|
-
video.controls = true;
|
|
715
|
-
// 비디오도 이미지와 같은 스타일 적용
|
|
716
|
-
this.applyStyles(video, this.getImageStyles(slot));
|
|
717
|
-
// 비디오 이벤트 추적
|
|
718
|
-
video.addEventListener('play', () => {
|
|
719
|
-
this.trackEvent?.(ad._id, slot.id, 'VIDEO_START');
|
|
720
|
-
});
|
|
721
|
-
video.addEventListener('ended', () => {
|
|
722
|
-
this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
|
|
723
|
-
});
|
|
724
|
-
return video;
|
|
742
|
+
initMaxMeasureTimer() {
|
|
743
|
+
// 최대 측정 시간 후 자동 종료
|
|
744
|
+
this.maxVisibilityTimer = setTimeout(() => {
|
|
745
|
+
this.destroy();
|
|
746
|
+
}, this.config.maxMeasureTime);
|
|
725
747
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
class AdRendererFactory {
|
|
734
|
-
/**
|
|
735
|
-
* 광고 타입에 맞는 렌더러 생성
|
|
736
|
-
*/
|
|
737
|
-
static createRenderer(adType, trackEvent) {
|
|
738
|
-
const RendererClass = this.renderers.get(adType);
|
|
739
|
-
if (!RendererClass) {
|
|
740
|
-
console.warn(`No renderer found for ad type: ${adType}, falling back to Banner renderer`);
|
|
741
|
-
return new BannerAdRenderer(trackEvent);
|
|
748
|
+
onViewabilityAchieved() {
|
|
749
|
+
if (this.isViewableAchieved)
|
|
750
|
+
return;
|
|
751
|
+
this.isViewableAchieved = true;
|
|
752
|
+
const metrics = this.calculateMetrics();
|
|
753
|
+
if (this.onViewableCallback) {
|
|
754
|
+
this.onViewableCallback(metrics);
|
|
742
755
|
}
|
|
743
|
-
return new RendererClass(trackEvent);
|
|
744
756
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
return
|
|
757
|
+
calculateMetrics() {
|
|
758
|
+
const currentTime = performance.now();
|
|
759
|
+
const exposureTime = this.firstViewableTime
|
|
760
|
+
? currentTime - this.firstViewableTime
|
|
761
|
+
: 0;
|
|
762
|
+
return {
|
|
763
|
+
isViewable: this.isViewableAchieved,
|
|
764
|
+
exposureTime,
|
|
765
|
+
maxVisibilityRatio: this.maxVisibilityRatio,
|
|
766
|
+
firstViewableTime: this.firstViewableTime,
|
|
767
|
+
measureStartTime: this.startTime,
|
|
768
|
+
};
|
|
751
769
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
*/
|
|
755
|
-
static getSupportedAdTypes() {
|
|
756
|
-
return Array.from(this.renderers.keys());
|
|
770
|
+
getMetrics() {
|
|
771
|
+
return this.calculateMetrics();
|
|
757
772
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
773
|
+
destroy() {
|
|
774
|
+
this.stopViewabilityTimer();
|
|
775
|
+
if (this.maxVisibilityTimer) {
|
|
776
|
+
clearTimeout(this.maxVisibilityTimer);
|
|
777
|
+
this.maxVisibilityTimer = null;
|
|
778
|
+
}
|
|
779
|
+
if (this.observer) {
|
|
780
|
+
this.observer.disconnect();
|
|
781
|
+
this.observer = null;
|
|
782
|
+
}
|
|
763
783
|
}
|
|
764
784
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* BasicFraudDetector - 현실적 구현 버전
|
|
788
|
+
* 기본적인 봇 탐지 및 간단한 행동 패턴 분석
|
|
789
|
+
*/
|
|
790
|
+
class BasicFraudDetector {
|
|
791
|
+
constructor() {
|
|
792
|
+
this.mouseEvents = 0;
|
|
793
|
+
this.keyboardEvents = 0;
|
|
794
|
+
this.scrollEvents = 0;
|
|
795
|
+
this.startTime = Date.now();
|
|
796
|
+
this.initBasicTracking();
|
|
797
|
+
}
|
|
798
|
+
initBasicTracking() {
|
|
799
|
+
// 기본적인 사용자 상호작용 추적
|
|
800
|
+
document.addEventListener('mousemove', () => this.mouseEvents++, { passive: true });
|
|
801
|
+
document.addEventListener('keydown', () => this.keyboardEvents++, { passive: true });
|
|
802
|
+
document.addEventListener('scroll', () => this.scrollEvents++, { passive: true });
|
|
803
|
+
}
|
|
804
|
+
calculateFraudScore() {
|
|
805
|
+
let score = 0;
|
|
806
|
+
const reasons = [];
|
|
807
|
+
// 1. 웹드라이버 탐지 (기본)
|
|
808
|
+
if (this.detectWebDriver()) {
|
|
809
|
+
score += 50;
|
|
810
|
+
reasons.push('WebDriver detected');
|
|
811
|
+
}
|
|
812
|
+
// 2. 헤드리스 브라우저 기본 탐지
|
|
813
|
+
if (this.detectBasicHeadless()) {
|
|
814
|
+
score += 40;
|
|
815
|
+
reasons.push('Headless browser signatures');
|
|
816
|
+
}
|
|
817
|
+
// 3. 사용자 상호작용 부족
|
|
818
|
+
const sessionTime = Date.now() - this.startTime;
|
|
819
|
+
if (sessionTime > 5000) { // 5초 이상 경과
|
|
820
|
+
if (this.mouseEvents === 0) {
|
|
821
|
+
score += 20;
|
|
822
|
+
reasons.push('No mouse interaction');
|
|
823
|
+
}
|
|
824
|
+
if (this.scrollEvents === 0 && sessionTime > 10000) {
|
|
825
|
+
score += 15;
|
|
826
|
+
reasons.push('No scroll activity');
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// 4. 브라우저 환경 이상 징후
|
|
830
|
+
const browserCheck = this.checkBrowserEnvironment();
|
|
831
|
+
score += browserCheck.score;
|
|
832
|
+
reasons.push(...browserCheck.reasons);
|
|
833
|
+
// 5. 시간 패턴 이상 (너무 빠른 페이지 로드 후 즉시 클릭)
|
|
834
|
+
if (sessionTime < 1000) {
|
|
835
|
+
score += 25;
|
|
836
|
+
reasons.push('Suspiciously fast interaction');
|
|
837
|
+
}
|
|
838
|
+
const finalScore = Math.min(score, 100);
|
|
839
|
+
return {
|
|
840
|
+
score: finalScore,
|
|
841
|
+
riskLevel: this.getRiskLevel(finalScore),
|
|
842
|
+
reasons: reasons
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
detectWebDriver() {
|
|
846
|
+
// 기본적인 웹드라이버 탐지
|
|
847
|
+
return !!(window.webdriver ||
|
|
848
|
+
navigator.webdriver ||
|
|
849
|
+
window.__webdriver_evaluate ||
|
|
850
|
+
window.__selenium_evaluate ||
|
|
851
|
+
window.__webdriver_script_function ||
|
|
852
|
+
window.__webdriver_script_func ||
|
|
853
|
+
window.__webdriver_script_fn ||
|
|
854
|
+
window.__fxdriver_evaluate ||
|
|
855
|
+
window.__driver_unwrapped ||
|
|
856
|
+
window.__webdriver_unwrapped ||
|
|
857
|
+
window.__driver_evaluate ||
|
|
858
|
+
window.__selenium_unwrapped ||
|
|
859
|
+
window.__fxdriver_unwrapped);
|
|
860
|
+
}
|
|
861
|
+
detectBasicHeadless() {
|
|
862
|
+
const signatures = [];
|
|
863
|
+
// PhantomJS 탐지
|
|
864
|
+
if (window._phantom || window.phantom) {
|
|
865
|
+
signatures.push('PhantomJS');
|
|
866
|
+
}
|
|
867
|
+
// Chrome headless 기본 탐지
|
|
868
|
+
if (navigator.userAgent.includes('HeadlessChrome')) {
|
|
869
|
+
signatures.push('Chrome Headless');
|
|
870
|
+
}
|
|
871
|
+
// 플러그인 없음 (일반적이지 않음)
|
|
872
|
+
if (navigator.plugins.length === 0) {
|
|
873
|
+
signatures.push('No plugins');
|
|
874
|
+
}
|
|
875
|
+
return signatures.length > 0;
|
|
876
|
+
}
|
|
877
|
+
checkBrowserEnvironment() {
|
|
878
|
+
let score = 0;
|
|
879
|
+
const reasons = [];
|
|
880
|
+
// 언어 설정 이상
|
|
881
|
+
if (!navigator.language || navigator.language === 'C') {
|
|
882
|
+
score += 10;
|
|
883
|
+
reasons.push('Unusual language setting');
|
|
884
|
+
}
|
|
885
|
+
// 쿠키 비활성화
|
|
886
|
+
if (!navigator.cookieEnabled) {
|
|
887
|
+
score += 15;
|
|
888
|
+
reasons.push('Cookies disabled');
|
|
889
|
+
}
|
|
890
|
+
// 화면 해상도 이상
|
|
891
|
+
if (screen.width === 0 || screen.height === 0) {
|
|
892
|
+
score += 20;
|
|
893
|
+
reasons.push('Invalid screen resolution');
|
|
894
|
+
}
|
|
895
|
+
// 시간대 정보 없음
|
|
896
|
+
try {
|
|
897
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
898
|
+
if (!timezone || timezone === 'UTC') {
|
|
899
|
+
score += 5;
|
|
900
|
+
reasons.push('No timezone info');
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
catch (e) {
|
|
904
|
+
score += 10;
|
|
905
|
+
reasons.push('Timezone detection failed');
|
|
906
|
+
}
|
|
907
|
+
return { score, reasons };
|
|
908
|
+
}
|
|
909
|
+
getRiskLevel(score) {
|
|
910
|
+
if (score >= 70)
|
|
911
|
+
return 'CRITICAL';
|
|
912
|
+
if (score >= 50)
|
|
913
|
+
return 'HIGH';
|
|
914
|
+
if (score >= 30)
|
|
915
|
+
return 'MEDIUM';
|
|
916
|
+
return 'LOW';
|
|
917
|
+
}
|
|
918
|
+
getBrowserInfo() {
|
|
919
|
+
return {
|
|
920
|
+
userAgent: navigator.userAgent,
|
|
921
|
+
language: navigator.language,
|
|
922
|
+
platform: navigator.platform,
|
|
923
|
+
cookieEnabled: navigator.cookieEnabled,
|
|
924
|
+
doNotTrack: navigator.doNotTrack,
|
|
925
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
926
|
+
screenResolution: `${screen.width}x${screen.height}`,
|
|
927
|
+
viewportSize: `${window.innerWidth}x${window.innerHeight}`
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
destroy() {
|
|
931
|
+
// 이벤트 리스너 정리는 생략 (메모리 누수 방지를 위해 필요시 구현)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* AdStage SDK 엔드포인트 상수 관리
|
|
937
|
+
* 모든 API URL을 중앙에서 관리
|
|
938
|
+
*/
|
|
939
|
+
/**
|
|
940
|
+
* 환경별 API 엔드포인트 (읽기 전용)
|
|
941
|
+
*/
|
|
942
|
+
const API_ENDPOINTS = {
|
|
943
|
+
/** 프로덕션 환경 */
|
|
944
|
+
production: 'https://api.adstage.io',
|
|
945
|
+
/** 베타 환경 (기본값) */
|
|
946
|
+
beta: 'https://beta-api.adstage.app'
|
|
947
|
+
};
|
|
948
|
+
/**
|
|
949
|
+
* API 경로 상수
|
|
950
|
+
*/
|
|
951
|
+
const API_PATHS = {
|
|
952
|
+
/** 광고 관련 */
|
|
953
|
+
advertisements: {
|
|
954
|
+
list: '/advertisements/list',
|
|
955
|
+
events: '/advertisements/events'
|
|
956
|
+
},
|
|
957
|
+
/** 이벤트 관련 */
|
|
958
|
+
events: {
|
|
959
|
+
track: '/events/track',
|
|
960
|
+
batch: '/events/batch'
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
/**
|
|
964
|
+
* 완전한 API URL 생성 헬퍼
|
|
965
|
+
*/
|
|
966
|
+
class EndpointBuilder {
|
|
967
|
+
constructor(baseUrl) {
|
|
968
|
+
/**
|
|
969
|
+
* 광고 엔드포인트
|
|
970
|
+
*/
|
|
971
|
+
this.advertisements = {
|
|
972
|
+
list: () => `${this.baseUrl}${API_PATHS.advertisements.list}`,
|
|
973
|
+
events: (adId, eventType) => `${this.baseUrl}${API_PATHS.advertisements.events}/${adId}/${eventType}`
|
|
974
|
+
};
|
|
975
|
+
/**
|
|
976
|
+
* 이벤트 엔드포인트
|
|
977
|
+
*/
|
|
978
|
+
this.events = {
|
|
979
|
+
track: () => `${this.baseUrl}${API_PATHS.events.track}`,
|
|
980
|
+
batch: () => `${this.baseUrl}${API_PATHS.events.batch}`
|
|
981
|
+
};
|
|
982
|
+
// 기본값은 베타 환경 사용
|
|
983
|
+
this.baseUrl = baseUrl || API_ENDPOINTS.beta;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* 기본 URL 변경
|
|
987
|
+
*/
|
|
988
|
+
setBaseUrl(url) {
|
|
989
|
+
this.baseUrl = url;
|
|
990
|
+
console.log('🔄 API endpoint changed:', url);
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* 기본 URL 반환
|
|
994
|
+
*/
|
|
995
|
+
getBaseUrl() {
|
|
996
|
+
return this.baseUrl;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* 커스텀 경로 생성
|
|
1000
|
+
*/
|
|
1001
|
+
custom(path) {
|
|
1002
|
+
return `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* 전역 엔드포인트 빌더 인스턴스 (기본: 베타 환경)
|
|
1007
|
+
*/
|
|
1008
|
+
const endpoints = new EndpointBuilder();
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* 기본 광고 렌더러 추상 클래스
|
|
1012
|
+
*/
|
|
1013
|
+
class BaseAdRenderer {
|
|
1014
|
+
constructor(trackEvent) {
|
|
1015
|
+
this.trackEvent = trackEvent;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* 공통 클릭 이벤트 핸들러 (SSR 안전)
|
|
1019
|
+
*/
|
|
1020
|
+
addClickHandler(element, ad, slot) {
|
|
1021
|
+
DOMUtils.safeAddEventListener(element, 'click', () => {
|
|
1022
|
+
this.trackEvent?.(ad._id, slot.id, 'CLICK');
|
|
1023
|
+
if (ad.linkUrl) {
|
|
1024
|
+
DOMUtils.safeWindowOpen(ad.linkUrl, '_blank');
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* 공통 스타일 적용 유틸리티 (SSR 안전)
|
|
1030
|
+
*/
|
|
1031
|
+
applyStyles(element, styles) {
|
|
1032
|
+
DOMUtils.safeApplyStyles(element, styles);
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* 크기 값 파싱 유틸리티 (px, %, number 지원)
|
|
1036
|
+
*/
|
|
1037
|
+
parseSizeValue(value) {
|
|
1038
|
+
if (!value)
|
|
1039
|
+
return undefined;
|
|
1040
|
+
if (typeof value === 'number') {
|
|
1041
|
+
return value > 0 ? `${value}px` : undefined;
|
|
1042
|
+
}
|
|
1043
|
+
if (typeof value === 'string') {
|
|
1044
|
+
const trimmed = value.trim();
|
|
1045
|
+
if (!trimmed)
|
|
1046
|
+
return undefined;
|
|
1047
|
+
// 퍼센트 값 처리
|
|
1048
|
+
if (trimmed.endsWith('%')) {
|
|
1049
|
+
const percent = parseFloat(trimmed);
|
|
1050
|
+
return !isNaN(percent) && percent > 0 ? trimmed : undefined;
|
|
1051
|
+
}
|
|
1052
|
+
// px 값 처리 (px 단위 포함/미포함 모두 지원)
|
|
1053
|
+
const numValue = trimmed.endsWith('px')
|
|
1054
|
+
? parseFloat(trimmed.slice(0, -2))
|
|
1055
|
+
: parseFloat(trimmed);
|
|
1056
|
+
return !isNaN(numValue) && numValue > 0 ? `${numValue}px` : undefined;
|
|
1057
|
+
}
|
|
1058
|
+
return undefined;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* 기본 컨테이너 스타일 (사용자 지정 크기만 적용)
|
|
1062
|
+
*/
|
|
1063
|
+
getBaseContainerStyles(slot) {
|
|
1064
|
+
const styles = {
|
|
1065
|
+
cursor: 'pointer',
|
|
1066
|
+
position: 'relative',
|
|
1067
|
+
overflow: 'hidden',
|
|
1068
|
+
};
|
|
1069
|
+
// 사용자가 지정한 크기가 있을 때만 적용
|
|
1070
|
+
const parsedWidth = this.parseSizeValue(slot.width);
|
|
1071
|
+
const parsedHeight = this.parseSizeValue(slot.height);
|
|
1072
|
+
if (parsedWidth) {
|
|
1073
|
+
styles.width = parsedWidth;
|
|
1074
|
+
}
|
|
1075
|
+
if (parsedHeight) {
|
|
1076
|
+
styles.height = parsedHeight;
|
|
1077
|
+
}
|
|
1078
|
+
return styles;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* 이미지 스타일 (고유 사이즈 유지, 사용자 지정 크기 우선)
|
|
1082
|
+
*/
|
|
1083
|
+
getImageStyles(slot) {
|
|
1084
|
+
const styles = {
|
|
1085
|
+
display: 'block',
|
|
1086
|
+
'max-width': '100%',
|
|
1087
|
+
height: 'auto',
|
|
1088
|
+
};
|
|
1089
|
+
// 사용자가 컨테이너 크기를 지정한 경우에만 크기 제한
|
|
1090
|
+
const parsedWidth = this.parseSizeValue(slot?.width);
|
|
1091
|
+
const parsedHeight = this.parseSizeValue(slot?.height);
|
|
1092
|
+
if (parsedWidth && parsedHeight) {
|
|
1093
|
+
styles.width = '100%';
|
|
1094
|
+
styles.height = '100%';
|
|
1095
|
+
styles['object-fit'] = 'cover';
|
|
1096
|
+
}
|
|
1097
|
+
return styles;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* 기본 폰트 스타일
|
|
1101
|
+
*/
|
|
1102
|
+
getBaseFontStyles() {
|
|
1103
|
+
return {
|
|
1104
|
+
'font-family': 'Arial, sans-serif',
|
|
1105
|
+
'line-height': '1.4',
|
|
1106
|
+
'word-break': 'keep-all',
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* 이미지 요소 생성 (SSR 안전)
|
|
1111
|
+
*/
|
|
1112
|
+
createImageElement(imageUrl, alt = '', slot) {
|
|
1113
|
+
const img = DOMUtils.safeCreateElement('img');
|
|
1114
|
+
if (!img)
|
|
1115
|
+
return null;
|
|
1116
|
+
img.src = imageUrl;
|
|
1117
|
+
img.alt = alt;
|
|
1118
|
+
this.applyStyles(img, this.getImageStyles(slot));
|
|
1119
|
+
return img;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* 텍스트 요소 생성 (SSR 안전)
|
|
1123
|
+
*/
|
|
1124
|
+
createTextElement(text, tag = 'div', additionalStyles = {}) {
|
|
1125
|
+
const element = DOMUtils.safeCreateElement(tag);
|
|
1126
|
+
if (!element)
|
|
1127
|
+
return null;
|
|
1128
|
+
DOMUtils.safeSetTextContent(element, text);
|
|
1129
|
+
this.applyStyles(element, {
|
|
1130
|
+
...this.getBaseFontStyles(),
|
|
1131
|
+
...additionalStyles,
|
|
1132
|
+
});
|
|
1133
|
+
return element;
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* 플레이스홀더 요소 생성
|
|
1137
|
+
*/
|
|
1138
|
+
createPlaceholder(slot, text = '광고') {
|
|
1139
|
+
let placeholder = DOMUtils.safeCreateElement('div');
|
|
1140
|
+
// SSR 환경에서 DOM을 사용할 수 없는 경우, 런타임에 생성되도록 함
|
|
1141
|
+
if (!placeholder) {
|
|
1142
|
+
// SSR에서는 빈 div를 반환하되, 브라우저에서는 제대로 작동하도록 함
|
|
1143
|
+
if (typeof document !== 'undefined') {
|
|
1144
|
+
placeholder = document.createElement('div');
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
// SSR 환경에서는 더미 객체 반환 (타입 단언 사용)
|
|
1148
|
+
placeholder = {};
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// DOM이 사용 가능한 경우에만 스타일 적용
|
|
1152
|
+
if (DOMUtils.canUseDOM() && placeholder) {
|
|
1153
|
+
this.applyStyles(placeholder, {
|
|
1154
|
+
...this.getBaseContainerStyles(slot),
|
|
1155
|
+
background: '#f8f9fa',
|
|
1156
|
+
display: 'flex',
|
|
1157
|
+
'align-items': 'center',
|
|
1158
|
+
'justify-content': 'center',
|
|
1159
|
+
color: '#6c757d',
|
|
1160
|
+
...this.getBaseFontStyles(),
|
|
1161
|
+
// 플레이스홀더는 최소 크기 보장
|
|
1162
|
+
'min-width': '100px',
|
|
1163
|
+
'min-height': '100px',
|
|
1164
|
+
});
|
|
1165
|
+
DOMUtils.safeSetTextContent(placeholder, text);
|
|
1166
|
+
}
|
|
1167
|
+
return placeholder;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* 배너 광고 렌더러 - 이미지만 표시
|
|
1173
|
+
*/
|
|
1174
|
+
class BannerAdRenderer extends BaseAdRenderer {
|
|
1175
|
+
render(ad, slot) {
|
|
1176
|
+
const adElement = DOMUtils.safeCreateElement('div');
|
|
1177
|
+
if (!adElement) {
|
|
1178
|
+
// SSR 환경에서는 기본 div 반환
|
|
1179
|
+
return document.createElement('div');
|
|
1180
|
+
}
|
|
1181
|
+
// 기본 컨테이너 스타일 적용 (불필요한 스타일 제거)
|
|
1182
|
+
this.applyStyles(adElement, this.getBaseContainerStyles(slot));
|
|
1183
|
+
// 배너 광고는 이미지만 표시
|
|
1184
|
+
if (!ad.imageUrl) {
|
|
1185
|
+
// 이미지가 없는 경우 플레이스홀더 반환
|
|
1186
|
+
const placeholder = this.createPlaceholder(slot, '배너 광고');
|
|
1187
|
+
return placeholder || adElement;
|
|
1188
|
+
}
|
|
1189
|
+
const img = this.createImageElement(ad.imageUrl, '', slot);
|
|
1190
|
+
if (img) {
|
|
1191
|
+
DOMUtils.safeAppendChild(adElement, img);
|
|
1192
|
+
// 클릭 이벤트 추가
|
|
1193
|
+
this.addClickHandler(adElement, ad, slot);
|
|
1194
|
+
}
|
|
1195
|
+
return adElement;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* 텍스트 광고 렌더러 - textContent만 표시
|
|
1201
|
+
*/
|
|
1202
|
+
class TextAdRenderer extends BaseAdRenderer {
|
|
1203
|
+
render(ad, slot) {
|
|
1204
|
+
let adElement = DOMUtils.safeCreateElement('div');
|
|
1205
|
+
if (!adElement) {
|
|
1206
|
+
return this.createPlaceholder(slot, '텍스트 광고');
|
|
1207
|
+
}
|
|
1208
|
+
// 기본 컨테이너 스타일
|
|
1209
|
+
const containerStyles = {
|
|
1210
|
+
...this.getBaseContainerStyles(slot),
|
|
1211
|
+
padding: '20px',
|
|
1212
|
+
background: 'transparent',
|
|
1213
|
+
display: 'flex',
|
|
1214
|
+
'align-items': 'center',
|
|
1215
|
+
'justify-content': 'center',
|
|
1216
|
+
// text-align은 사용자가 설정할 수 있도록 기본값에서 제외
|
|
1217
|
+
...this.getBaseFontStyles(),
|
|
1218
|
+
};
|
|
1219
|
+
// 사용자가 크기를 지정하지 않은 경우 컨텐츠에 맞춤
|
|
1220
|
+
if (!slot.width || slot.width === 0) {
|
|
1221
|
+
containerStyles.display = 'inline-flex';
|
|
1222
|
+
containerStyles['white-space'] = 'nowrap';
|
|
1223
|
+
containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
|
|
1224
|
+
}
|
|
1225
|
+
// height만 자동인 경우 줄바꿈 허용
|
|
1226
|
+
if ((slot.width && slot.width !== 0) && (!slot.height || slot.height === 0)) {
|
|
1227
|
+
containerStyles['white-space'] = 'normal';
|
|
1228
|
+
containerStyles['min-height'] = 'auto';
|
|
1229
|
+
containerStyles['justify-content'] = 'flex-start'; // 좌측 정렬로 변경
|
|
1230
|
+
}
|
|
1231
|
+
this.applyStyles(adElement, containerStyles);
|
|
1232
|
+
// 텍스트 광고는 textContent만 표시
|
|
1233
|
+
if (!ad.textContent) {
|
|
1234
|
+
// 텍스트가 없는 경우 플레이스홀더 반환
|
|
1235
|
+
return this.createPlaceholder(slot, '텍스트 광고');
|
|
1236
|
+
}
|
|
1237
|
+
// 텍스트 콘텐츠 생성
|
|
1238
|
+
const textContent = this.createTextElement(ad.textContent, 'div', {
|
|
1239
|
+
'font-size': '16px',
|
|
1240
|
+
'font-weight': '500',
|
|
1241
|
+
color: '#212529',
|
|
1242
|
+
width: '100%', // 전체 너비 사용하여 텍스트 정렬이 적용되도록 함
|
|
1243
|
+
});
|
|
1244
|
+
if (textContent) {
|
|
1245
|
+
adElement.appendChild(textContent);
|
|
1246
|
+
}
|
|
1247
|
+
// 사용자가 text-align을 지정했는지 확인하고 레이아웃 조정
|
|
1248
|
+
setTimeout(() => {
|
|
1249
|
+
if (!adElement || typeof window === 'undefined')
|
|
1250
|
+
return;
|
|
1251
|
+
const computedStyle = window.getComputedStyle(adElement);
|
|
1252
|
+
const textAlign = computedStyle.textAlign;
|
|
1253
|
+
// 사용자가 text-align을 설정했고, width가 없는 경우
|
|
1254
|
+
if (textAlign && textAlign !== 'start' && textAlign !== 'left' && (!slot.width || slot.width === 0)) {
|
|
1255
|
+
// 블록 레벨로 변경하여 text-align이 제대로 작동하도록 함
|
|
1256
|
+
adElement.style.display = 'block';
|
|
1257
|
+
adElement.style.whiteSpace = 'normal';
|
|
1258
|
+
// 최소 너비 설정 (텍스트가 한 줄일 때를 위해)
|
|
1259
|
+
if (textContent) {
|
|
1260
|
+
const textRect = textContent.getBoundingClientRect();
|
|
1261
|
+
if (textRect.width > 0) {
|
|
1262
|
+
adElement.style.minWidth = `${textRect.width}px`;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}, 0);
|
|
1267
|
+
// 클릭 이벤트 추가
|
|
1268
|
+
this.addClickHandler(adElement, ad, slot);
|
|
1269
|
+
return adElement;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* 네이티브 광고 렌더러 - 이미지 + textContent 표시
|
|
1275
|
+
*/
|
|
1276
|
+
class NativeAdRenderer extends BaseAdRenderer {
|
|
1277
|
+
render(ad, slot) {
|
|
1278
|
+
const adElement = DOMUtils.safeCreateElement('div');
|
|
1279
|
+
if (!adElement) {
|
|
1280
|
+
return document.createElement('div');
|
|
1281
|
+
}
|
|
1282
|
+
// 컨테이너 스타일 적용 (불필요한 스타일 제거)
|
|
1283
|
+
this.applyStyles(adElement, this.getBaseContainerStyles(slot));
|
|
1284
|
+
// 네이티브 광고는 이미지만 표시
|
|
1285
|
+
if (!ad.imageUrl) {
|
|
1286
|
+
// 이미지가 없는 경우 플레이스홀더 반환
|
|
1287
|
+
const placeholder = this.createPlaceholder(slot, '네이티브 광고');
|
|
1288
|
+
return placeholder || adElement;
|
|
1289
|
+
}
|
|
1290
|
+
// 이미지 생성 (고유 사이즈 또는 사용자 지정 크기)
|
|
1291
|
+
const img = this.createImageElement(ad.imageUrl, '', slot);
|
|
1292
|
+
if (img) {
|
|
1293
|
+
DOMUtils.safeAppendChild(adElement, img);
|
|
1294
|
+
// 클릭 이벤트 추가
|
|
1295
|
+
this.addClickHandler(adElement, ad, slot);
|
|
1296
|
+
}
|
|
1297
|
+
return adElement;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* 비디오 광고 렌더러 - 비디오 또는 이미지 표시
|
|
1303
|
+
*/
|
|
1304
|
+
class VideoAdRenderer extends BaseAdRenderer {
|
|
1305
|
+
render(ad, slot) {
|
|
1306
|
+
let adElement = DOMUtils.safeCreateElement('div');
|
|
1307
|
+
if (!adElement) {
|
|
1308
|
+
return this.createPlaceholder(slot, '비디오 광고');
|
|
1309
|
+
}
|
|
1310
|
+
// 컨테이너 스타일 적용 (불필요한 스타일 제거)
|
|
1311
|
+
this.applyStyles(adElement, {
|
|
1312
|
+
...this.getBaseContainerStyles(slot),
|
|
1313
|
+
background: '#000',
|
|
1314
|
+
});
|
|
1315
|
+
// 비디오 광고는 비디오만 표시
|
|
1316
|
+
if (!ad.videoUrl) {
|
|
1317
|
+
// 비디오가 없는 경우 플레이스홀더 반환
|
|
1318
|
+
return this.createPlaceholder(slot, '비디오 광고');
|
|
1319
|
+
}
|
|
1320
|
+
const video = this.createVideoElement(ad.videoUrl, ad, slot);
|
|
1321
|
+
if (video) {
|
|
1322
|
+
adElement.appendChild(video);
|
|
1323
|
+
}
|
|
1324
|
+
// 클릭 이벤트 추가
|
|
1325
|
+
this.addClickHandler(adElement, ad, slot);
|
|
1326
|
+
return adElement;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* 비디오 요소 생성
|
|
1330
|
+
*/
|
|
1331
|
+
createVideoElement(videoUrl, ad, slot) {
|
|
1332
|
+
const video = DOMUtils.safeCreateElement('video');
|
|
1333
|
+
if (!video)
|
|
1334
|
+
return null;
|
|
1335
|
+
video.src = videoUrl;
|
|
1336
|
+
video.controls = true;
|
|
1337
|
+
// 비디오도 이미지와 같은 스타일 적용
|
|
1338
|
+
this.applyStyles(video, this.getImageStyles(slot));
|
|
1339
|
+
// 비디오 이벤트 추적
|
|
1340
|
+
video.addEventListener('play', () => {
|
|
1341
|
+
this.trackEvent?.(ad._id, slot.id, 'VIDEO_START');
|
|
1342
|
+
});
|
|
1343
|
+
video.addEventListener('ended', () => {
|
|
1344
|
+
this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
|
|
1345
|
+
});
|
|
1346
|
+
return video;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* 전면/팝업 광고 렌더러 - 핵심 콘텐츠만 표시
|
|
1352
|
+
*/
|
|
1353
|
+
class InterstitialAdRenderer extends BaseAdRenderer {
|
|
1354
|
+
render(ad, slot) {
|
|
1355
|
+
let adElement = DOMUtils.safeCreateElement('div');
|
|
1356
|
+
if (!adElement) {
|
|
1357
|
+
return this.createPlaceholder(slot, '전면 광고');
|
|
1358
|
+
}
|
|
1359
|
+
// 컨테이너 스타일 적용 (불필요한 스타일 제거)
|
|
1360
|
+
this.applyStyles(adElement, {
|
|
1361
|
+
...this.getBaseContainerStyles(slot),
|
|
1362
|
+
display: 'flex',
|
|
1363
|
+
'flex-direction': 'column',
|
|
1364
|
+
});
|
|
1365
|
+
// 우선순위: 1. 이미지, 2. 비디오, 3. 텍스트
|
|
1366
|
+
if (ad.imageUrl) {
|
|
1367
|
+
const img = this.createImageElement(ad.imageUrl, '', slot);
|
|
1368
|
+
if (img) {
|
|
1369
|
+
adElement.appendChild(img);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
else if (ad.videoUrl) {
|
|
1373
|
+
// 이미지가 없고 비디오가 있는 경우
|
|
1374
|
+
const video = this.createVideoElement(ad.videoUrl, ad, slot);
|
|
1375
|
+
if (video) {
|
|
1376
|
+
adElement.appendChild(video);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
else {
|
|
1380
|
+
// 모든 콘텐츠가 없는 경우
|
|
1381
|
+
return this.createPlaceholder(slot, '전면 광고');
|
|
1382
|
+
}
|
|
1383
|
+
// 클릭 이벤트 추가
|
|
1384
|
+
this.addClickHandler(adElement, ad, slot);
|
|
1385
|
+
return adElement;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* 비디오 요소 생성
|
|
1389
|
+
*/
|
|
1390
|
+
createVideoElement(videoUrl, ad, slot) {
|
|
1391
|
+
const video = DOMUtils.safeCreateElement('video');
|
|
1392
|
+
if (!video)
|
|
1393
|
+
return null;
|
|
1394
|
+
video.src = videoUrl;
|
|
1395
|
+
video.controls = true;
|
|
1396
|
+
// 비디오도 이미지와 같은 스타일 적용
|
|
1397
|
+
this.applyStyles(video, this.getImageStyles(slot));
|
|
1398
|
+
// 비디오 이벤트 추적
|
|
1399
|
+
video.addEventListener('play', () => {
|
|
1400
|
+
this.trackEvent?.(ad._id, slot.id, 'VIDEO_START');
|
|
1401
|
+
});
|
|
1402
|
+
video.addEventListener('ended', () => {
|
|
1403
|
+
this.trackEvent?.(ad._id, slot.id, 'VIDEO_COMPLETE');
|
|
1404
|
+
});
|
|
1405
|
+
return video;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
var _a;
|
|
1410
|
+
/**
|
|
1411
|
+
* 광고 렌더러 팩토리
|
|
1412
|
+
* - 광고 타입에 따라 적절한 렌더러 인스턴스를 반환
|
|
1413
|
+
*/
|
|
1414
|
+
class AdRendererFactory {
|
|
1415
|
+
/**
|
|
1416
|
+
* 광고 타입에 맞는 렌더러 생성
|
|
1417
|
+
*/
|
|
1418
|
+
static createRenderer(adType, trackEvent) {
|
|
1419
|
+
const RendererClass = this.renderers.get(adType);
|
|
1420
|
+
if (!RendererClass) {
|
|
1421
|
+
console.warn(`No renderer found for ad type: ${adType}, falling back to Banner renderer`);
|
|
1422
|
+
return new BannerAdRenderer(trackEvent);
|
|
1423
|
+
}
|
|
1424
|
+
return new RendererClass(trackEvent);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* 광고 렌더링 (편의 메서드)
|
|
1428
|
+
*/
|
|
1429
|
+
static render(ad, slot, trackEvent) {
|
|
1430
|
+
const renderer = this.createRenderer(slot.adType, trackEvent);
|
|
1431
|
+
return renderer.render(ad, slot);
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* 사용 가능한 렌더러 타입 목록
|
|
1435
|
+
*/
|
|
1436
|
+
static getSupportedAdTypes() {
|
|
1437
|
+
return Array.from(this.renderers.keys());
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* 커스텀 렌더러 등록
|
|
1441
|
+
*/
|
|
1442
|
+
static registerRenderer(adType, RendererClass) {
|
|
1443
|
+
this.renderers.set(adType, RendererClass);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
_a = AdRendererFactory;
|
|
1447
|
+
AdRendererFactory.renderers = new Map();
|
|
1448
|
+
(() => {
|
|
1449
|
+
// 렌더러 등록
|
|
1450
|
+
_a.renderers.set(AdType.BANNER, BannerAdRenderer);
|
|
770
1451
|
_a.renderers.set(AdType.TEXT, TextAdRenderer);
|
|
771
1452
|
_a.renderers.set(AdType.NATIVE, NativeAdRenderer);
|
|
772
1453
|
_a.renderers.set(AdType.VIDEO, VideoAdRenderer);
|
|
@@ -805,12 +1486,11 @@ class CarouselSliderManager {
|
|
|
805
1486
|
width = `${slot.width}px`;
|
|
806
1487
|
}
|
|
807
1488
|
containerStyles.width = width;
|
|
808
|
-
containerStyles.display = 'block'; //
|
|
1489
|
+
containerStyles.display = 'inline-block'; // 지정된 크기에 맞춤 (좌측 정렬)
|
|
809
1490
|
}
|
|
810
1491
|
else {
|
|
811
|
-
// 컨텐츠 크기에 맞춤
|
|
812
|
-
containerStyles.display = 'block';
|
|
813
|
-
containerStyles.width = 'fit-content'; // 컨텐츠 크기에 맞춤
|
|
1492
|
+
// 컨텐츠 크기에 맞춤
|
|
1493
|
+
containerStyles.display = 'inline-block';
|
|
814
1494
|
}
|
|
815
1495
|
if (slot.height && slot.height !== 0) {
|
|
816
1496
|
const height = typeof slot.height === 'string' ? slot.height : `${slot.height}px`;
|
|
@@ -919,7 +1599,7 @@ class CarouselSliderManager {
|
|
|
919
1599
|
let currentSlide = 0;
|
|
920
1600
|
const totalSlides = advertisements.length;
|
|
921
1601
|
const autoSlideInterval = (options?.autoSlideInterval || 3) * 1000; // 기본 3초
|
|
922
|
-
// 슬라이드 이동 함수 (무한 루프 지원
|
|
1602
|
+
// 슬라이드 이동 함수 (무한 루프 지원)
|
|
923
1603
|
const moveToSlide = (index, instant = false) => {
|
|
924
1604
|
currentSlide = index;
|
|
925
1605
|
// 애니메이션 임시 비활성화 (무한 루프용)
|
|
@@ -931,33 +1611,6 @@ class CarouselSliderManager {
|
|
|
931
1611
|
}
|
|
932
1612
|
// 항상 퍼센트 기반으로 이동
|
|
933
1613
|
slideContainer.style.transform = `translateX(-${(100 / extendedAds.length) * currentSlide}%)`;
|
|
934
|
-
// 🆕 동적 높이 조정: 현재 슬라이드의 이미지 높이에 맞춰 컨테이너 높이 조정
|
|
935
|
-
if (!instant && !slot.height && !slot.width) { // 사용자가 크기를 지정하지 않은 경우에만
|
|
936
|
-
const currentSlideElement = slideContainer.children[currentSlide];
|
|
937
|
-
if (currentSlideElement) {
|
|
938
|
-
const currentAdElement = currentSlideElement.children[0];
|
|
939
|
-
if (currentAdElement) {
|
|
940
|
-
// 이미지 요소 찾기
|
|
941
|
-
const imgElement = currentAdElement.querySelector('img');
|
|
942
|
-
if (imgElement) {
|
|
943
|
-
// 이미지 로드 완료 후 높이 조정
|
|
944
|
-
const adjustHeight = () => {
|
|
945
|
-
const imgHeight = imgElement.getBoundingClientRect().height;
|
|
946
|
-
if (imgHeight > 0) {
|
|
947
|
-
sliderWrapper.style.height = `${imgHeight}px`;
|
|
948
|
-
sliderWrapper.style.transition = instant ? 'none' : 'height 0.4s ease-out';
|
|
949
|
-
}
|
|
950
|
-
};
|
|
951
|
-
if (imgElement.complete && imgElement.naturalHeight > 0) {
|
|
952
|
-
adjustHeight();
|
|
953
|
-
}
|
|
954
|
-
else {
|
|
955
|
-
imgElement.addEventListener('load', adjustHeight, { once: true });
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
1614
|
// 도트 업데이트 (무채색 스타일) - 실제 광고 인덱스 기준, 텍스트 광고가 아닐 때만
|
|
962
1615
|
const actualIndex = currentSlide === totalSlides ? 0 : currentSlide;
|
|
963
1616
|
if (dotContainer) {
|
|
@@ -1089,252 +1742,24 @@ class CarouselSliderManager {
|
|
|
1089
1742
|
dot.style.opacity = '0.9';
|
|
1090
1743
|
}
|
|
1091
1744
|
});
|
|
1092
|
-
dot.addEventListener('mouseleave', () => {
|
|
1093
|
-
if (!dot.classList.contains('active')) {
|
|
1094
|
-
dot.style.borderColor = '#cccccc';
|
|
1095
|
-
dot.style.opacity = '0.7';
|
|
1096
|
-
}
|
|
1097
|
-
});
|
|
1098
|
-
dotContainer.appendChild(dot);
|
|
1099
|
-
}
|
|
1100
|
-
return dotContainer;
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* 터치 제스처 지원 추가
|
|
1104
|
-
*/
|
|
1105
|
-
static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides, handleInfiniteLoop) {
|
|
1106
|
-
let startX = 0;
|
|
1107
|
-
let isDragging = false;
|
|
1108
|
-
container.addEventListener('touchstart', (e) => {
|
|
1109
|
-
startX = e.touches[0].clientX;
|
|
1110
|
-
isDragging = true;
|
|
1111
|
-
});
|
|
1112
|
-
container.addEventListener('touchmove', (e) => {
|
|
1113
|
-
if (!isDragging)
|
|
1114
|
-
return;
|
|
1115
|
-
e.preventDefault();
|
|
1116
|
-
});
|
|
1117
|
-
container.addEventListener('touchend', (e) => {
|
|
1118
|
-
if (!isDragging)
|
|
1119
|
-
return;
|
|
1120
|
-
isDragging = false;
|
|
1121
|
-
const endX = e.changedTouches[0].clientX;
|
|
1122
|
-
const diff = startX - endX;
|
|
1123
|
-
if (Math.abs(diff) > 50) { // 50px 이상 스와이프 시
|
|
1124
|
-
const currentSlide = getCurrentSlide();
|
|
1125
|
-
if (diff > 0) {
|
|
1126
|
-
// 왼쪽으로 스와이프 (다음 슬라이드)
|
|
1127
|
-
const nextIndex = currentSlide + 1;
|
|
1128
|
-
moveToSlide(nextIndex);
|
|
1129
|
-
if (handleInfiniteLoop) {
|
|
1130
|
-
handleInfiniteLoop();
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
else {
|
|
1134
|
-
// 오른쪽으로 스와이프 (이전 슬라이드)
|
|
1135
|
-
const prevIndex = currentSlide > 0 ? currentSlide - 1 : totalSlides - 1;
|
|
1136
|
-
moveToSlide(prevIndex);
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* 텍스트 전환 효과 관리 클래스
|
|
1145
|
-
* - 텍스트 광고 전용 페이드 인/아웃 + 상하 움직임 효과
|
|
1146
|
-
* - 부드러운 전환 애니메이션 (vertical transition)
|
|
1147
|
-
* - 무한 루프 지원
|
|
1148
|
-
*/
|
|
1149
|
-
class TextTransitionManager {
|
|
1150
|
-
/**
|
|
1151
|
-
* 텍스트 전환 슬라이더 컨테이너 생성
|
|
1152
|
-
*/
|
|
1153
|
-
static createTextTransitionContainer(slot, advertisements, options, trackEventCallback) {
|
|
1154
|
-
const sliderWrapper = document.createElement('div');
|
|
1155
|
-
sliderWrapper.className = 'adstage-fade-slider-wrapper';
|
|
1156
|
-
// 래퍼 스타일 설정
|
|
1157
|
-
const containerStyles = {
|
|
1158
|
-
position: 'relative',
|
|
1159
|
-
overflow: 'hidden',
|
|
1160
|
-
display: 'block', // ✅ 하단 공백 제거를 위해 block 사용
|
|
1161
|
-
width: 'fit-content', // 컨텐츠 크기에 맞춤
|
|
1162
|
-
};
|
|
1163
|
-
// 사용자가 크기를 지정한 경우
|
|
1164
|
-
if (slot.width && slot.width !== 0) {
|
|
1165
|
-
let width;
|
|
1166
|
-
if (typeof slot.width === 'string') {
|
|
1167
|
-
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
1168
|
-
}
|
|
1169
|
-
else {
|
|
1170
|
-
width = `${slot.width}px`;
|
|
1171
|
-
}
|
|
1172
|
-
containerStyles.width = width;
|
|
1173
|
-
}
|
|
1174
|
-
if (slot.height && slot.height !== 0) {
|
|
1175
|
-
let height;
|
|
1176
|
-
if (typeof slot.height === 'string') {
|
|
1177
|
-
height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
|
|
1178
|
-
}
|
|
1179
|
-
else {
|
|
1180
|
-
height = `${slot.height}px`;
|
|
1181
|
-
}
|
|
1182
|
-
containerStyles.height = height;
|
|
1183
|
-
}
|
|
1184
|
-
// 스타일 적용
|
|
1185
|
-
Object.entries(containerStyles).forEach(([key, value]) => {
|
|
1186
|
-
sliderWrapper.style.setProperty(key, value);
|
|
1187
|
-
});
|
|
1188
|
-
// 슬라이드 컨테이너
|
|
1189
|
-
const slideContainer = document.createElement('div');
|
|
1190
|
-
slideContainer.className = 'adstage-fade-slide-container';
|
|
1191
|
-
slideContainer.style.cssText = `
|
|
1192
|
-
position: relative;
|
|
1193
|
-
width: 100%;
|
|
1194
|
-
height: 100%;
|
|
1195
|
-
`;
|
|
1196
|
-
// 크기 측정을 위한 임시 컨테이너 (자동 크기 계산이 필요한 경우)
|
|
1197
|
-
let measureContainer = null;
|
|
1198
|
-
const needsWidthMeasurement = !slot.width || slot.width === 0;
|
|
1199
|
-
const needsHeightMeasurement = !slot.height || slot.height === 0;
|
|
1200
|
-
if (needsWidthMeasurement || needsHeightMeasurement) {
|
|
1201
|
-
measureContainer = document.createElement('div');
|
|
1202
|
-
measureContainer.style.cssText = `
|
|
1203
|
-
position: absolute;
|
|
1204
|
-
visibility: hidden;
|
|
1205
|
-
white-space: nowrap;
|
|
1206
|
-
top: -9999px;
|
|
1207
|
-
left: -9999px;
|
|
1208
|
-
`;
|
|
1209
|
-
// width가 설정되어 있으면 측정 컨테이너에도 적용
|
|
1210
|
-
if (!needsWidthMeasurement && slot.width) {
|
|
1211
|
-
let width;
|
|
1212
|
-
if (typeof slot.width === 'string') {
|
|
1213
|
-
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
1214
|
-
}
|
|
1215
|
-
else {
|
|
1216
|
-
width = `${slot.width}px`;
|
|
1217
|
-
}
|
|
1218
|
-
measureContainer.style.width = width;
|
|
1219
|
-
measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
|
|
1220
|
-
}
|
|
1221
|
-
document.body.appendChild(measureContainer);
|
|
1222
|
-
let maxWidth = 0;
|
|
1223
|
-
let maxHeight = 0;
|
|
1224
|
-
// 모든 광고의 크기를 측정하여 최대 크기 찾기
|
|
1225
|
-
advertisements.forEach(ad => {
|
|
1226
|
-
const measureAdElement = AdRendererFactory.render(ad, slot, trackEventCallback);
|
|
1227
|
-
measureContainer.appendChild(measureAdElement);
|
|
1228
|
-
const rect = measureAdElement.getBoundingClientRect();
|
|
1229
|
-
if (rect.width > maxWidth)
|
|
1230
|
-
maxWidth = rect.width;
|
|
1231
|
-
if (rect.height > maxHeight)
|
|
1232
|
-
maxHeight = rect.height;
|
|
1233
|
-
// 측정 후 요소 제거
|
|
1234
|
-
measureContainer.removeChild(measureAdElement);
|
|
1235
|
-
});
|
|
1236
|
-
// 측정된 최대 크기로 래퍼 크기 설정
|
|
1237
|
-
if (needsWidthMeasurement && maxWidth > 0) {
|
|
1238
|
-
sliderWrapper.style.width = `${maxWidth}px`;
|
|
1239
|
-
}
|
|
1240
|
-
if (needsHeightMeasurement && maxHeight > 0) {
|
|
1241
|
-
sliderWrapper.style.height = `${maxHeight}px`;
|
|
1242
|
-
}
|
|
1243
|
-
// 측정 컨테이너 제거
|
|
1244
|
-
document.body.removeChild(measureContainer);
|
|
1245
|
-
}
|
|
1246
|
-
// 각 광고를 슬라이드로 생성
|
|
1247
|
-
const slideElements = [];
|
|
1248
|
-
advertisements.forEach((ad, index) => {
|
|
1249
|
-
const slideElement = document.createElement('div');
|
|
1250
|
-
slideElement.className = 'adstage-fade-slide';
|
|
1251
|
-
slideElement.style.cssText = `
|
|
1252
|
-
position: absolute;
|
|
1253
|
-
top: 0;
|
|
1254
|
-
left: 0;
|
|
1255
|
-
width: 100%;
|
|
1256
|
-
height: 100%;
|
|
1257
|
-
display: flex;
|
|
1258
|
-
align-items: center;
|
|
1259
|
-
justify-content: center;
|
|
1260
|
-
opacity: ${index === 0 ? '1' : '0'};
|
|
1261
|
-
transform: translateY(${index === 0 ? '0' : '20px'});
|
|
1262
|
-
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
1263
|
-
z-index: ${index === 0 ? '2' : '1'};
|
|
1264
|
-
`;
|
|
1265
|
-
// 광고 렌더링
|
|
1266
|
-
const adElement = AdRendererFactory.render(ad, slot, trackEventCallback);
|
|
1267
|
-
slideElement.appendChild(adElement);
|
|
1268
|
-
slideContainer.appendChild(slideElement);
|
|
1269
|
-
slideElements.push(slideElement);
|
|
1270
|
-
});
|
|
1271
|
-
// 슬라이더 상태 관리
|
|
1272
|
-
let currentSlide = 0;
|
|
1273
|
-
const totalSlides = advertisements.length;
|
|
1274
|
-
const autoSlideInterval = (options?.autoSlideInterval || 4) * 1000; // 기본 4초 (페이드는 조금 더 길게)
|
|
1275
|
-
// 슬라이드 이동 함수 (페이드 효과)
|
|
1276
|
-
const moveToSlide = (index) => {
|
|
1277
|
-
if (index >= totalSlides) {
|
|
1278
|
-
index = 0; // 무한 루프
|
|
1279
|
-
}
|
|
1280
|
-
else if (index < 0) {
|
|
1281
|
-
index = totalSlides - 1;
|
|
1282
|
-
}
|
|
1283
|
-
const previousSlide = slideElements[currentSlide];
|
|
1284
|
-
const nextSlide = slideElements[index];
|
|
1285
|
-
// 이전 슬라이드 페이드 아웃 (아래로)
|
|
1286
|
-
previousSlide.style.opacity = '0';
|
|
1287
|
-
previousSlide.style.transform = 'translateY(-20px)';
|
|
1288
|
-
previousSlide.style.zIndex = '1';
|
|
1289
|
-
// 다음 슬라이드 페이드 인 (위에서)
|
|
1290
|
-
nextSlide.style.opacity = '1';
|
|
1291
|
-
nextSlide.style.transform = 'translateY(0)';
|
|
1292
|
-
nextSlide.style.zIndex = '2';
|
|
1293
|
-
// 다른 슬라이드들은 숨김
|
|
1294
|
-
slideElements.forEach((slide, i) => {
|
|
1295
|
-
if (i !== index && i !== currentSlide) {
|
|
1296
|
-
slide.style.opacity = '0';
|
|
1297
|
-
slide.style.transform = 'translateY(20px)';
|
|
1298
|
-
slide.style.zIndex = '1';
|
|
1299
|
-
}
|
|
1300
|
-
});
|
|
1301
|
-
currentSlide = index;
|
|
1302
|
-
// 현재 슬라이드의 광고에 대해 노출 이벤트 추적
|
|
1303
|
-
if (currentSlide > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
|
|
1304
|
-
trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.VIEWABLE);
|
|
1305
|
-
}
|
|
1306
|
-
};
|
|
1307
|
-
// 자동 슬라이드
|
|
1308
|
-
let autoSlideTimer = setInterval(() => {
|
|
1309
|
-
const nextIndex = currentSlide + 1;
|
|
1310
|
-
moveToSlide(nextIndex);
|
|
1311
|
-
}, autoSlideInterval);
|
|
1312
|
-
// 마우스 호버 시 자동 슬라이드 일시정지
|
|
1313
|
-
sliderWrapper.addEventListener('mouseenter', () => {
|
|
1314
|
-
clearInterval(autoSlideTimer);
|
|
1315
|
-
});
|
|
1316
|
-
sliderWrapper.addEventListener('mouseleave', () => {
|
|
1317
|
-
autoSlideTimer = setInterval(() => {
|
|
1318
|
-
const nextIndex = currentSlide + 1;
|
|
1319
|
-
moveToSlide(nextIndex);
|
|
1320
|
-
}, autoSlideInterval);
|
|
1321
|
-
});
|
|
1322
|
-
// 터치 제스처 지원
|
|
1323
|
-
TextTransitionManager.addTouchSupport(sliderWrapper, moveToSlide, () => currentSlide, totalSlides);
|
|
1324
|
-
// 요소들 조립
|
|
1325
|
-
sliderWrapper.appendChild(slideContainer);
|
|
1326
|
-
return sliderWrapper;
|
|
1745
|
+
dot.addEventListener('mouseleave', () => {
|
|
1746
|
+
if (!dot.classList.contains('active')) {
|
|
1747
|
+
dot.style.borderColor = '#cccccc';
|
|
1748
|
+
dot.style.opacity = '0.7';
|
|
1749
|
+
}
|
|
1750
|
+
});
|
|
1751
|
+
dotContainer.appendChild(dot);
|
|
1752
|
+
}
|
|
1753
|
+
return dotContainer;
|
|
1327
1754
|
}
|
|
1328
1755
|
/**
|
|
1329
1756
|
* 터치 제스처 지원 추가
|
|
1330
1757
|
*/
|
|
1331
|
-
static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides) {
|
|
1758
|
+
static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides, handleInfiniteLoop) {
|
|
1332
1759
|
let startX = 0;
|
|
1333
|
-
let startY = 0;
|
|
1334
1760
|
let isDragging = false;
|
|
1335
1761
|
container.addEventListener('touchstart', (e) => {
|
|
1336
1762
|
startX = e.touches[0].clientX;
|
|
1337
|
-
startY = e.touches[0].clientY;
|
|
1338
1763
|
isDragging = true;
|
|
1339
1764
|
});
|
|
1340
1765
|
container.addEventListener('touchmove', (e) => {
|
|
@@ -1347,19 +1772,21 @@ class TextTransitionManager {
|
|
|
1347
1772
|
return;
|
|
1348
1773
|
isDragging = false;
|
|
1349
1774
|
const endX = e.changedTouches[0].clientX;
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
const diffY = startY - endY;
|
|
1353
|
-
// 가로 스와이프가 세로 스와이프보다 클 때만 처리
|
|
1354
|
-
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
|
1775
|
+
const diff = startX - endX;
|
|
1776
|
+
if (Math.abs(diff) > 50) { // 50px 이상 스와이프 시
|
|
1355
1777
|
const currentSlide = getCurrentSlide();
|
|
1356
|
-
if (
|
|
1778
|
+
if (diff > 0) {
|
|
1357
1779
|
// 왼쪽으로 스와이프 (다음 슬라이드)
|
|
1358
|
-
|
|
1780
|
+
const nextIndex = currentSlide + 1;
|
|
1781
|
+
moveToSlide(nextIndex);
|
|
1782
|
+
if (handleInfiniteLoop) {
|
|
1783
|
+
handleInfiniteLoop();
|
|
1784
|
+
}
|
|
1359
1785
|
}
|
|
1360
1786
|
else {
|
|
1361
1787
|
// 오른쪽으로 스와이프 (이전 슬라이드)
|
|
1362
|
-
|
|
1788
|
+
const prevIndex = currentSlide > 0 ? currentSlide - 1 : totalSlides - 1;
|
|
1789
|
+
moveToSlide(prevIndex);
|
|
1363
1790
|
}
|
|
1364
1791
|
}
|
|
1365
1792
|
});
|
|
@@ -1367,698 +1794,844 @@ class TextTransitionManager {
|
|
|
1367
1794
|
}
|
|
1368
1795
|
|
|
1369
1796
|
/**
|
|
1370
|
-
*
|
|
1371
|
-
* -
|
|
1372
|
-
* -
|
|
1373
|
-
* -
|
|
1374
|
-
* - ViewabilityTracker와 함께 사용하여 중복 viewable 이벤트 방지
|
|
1797
|
+
* 텍스트 전환 효과 관리 클래스
|
|
1798
|
+
* - 텍스트 광고 전용 페이드 인/아웃 + 상하 움직임 효과
|
|
1799
|
+
* - 부드러운 전환 애니메이션 (vertical transition)
|
|
1800
|
+
* - 무한 루프 지원
|
|
1375
1801
|
*/
|
|
1376
|
-
class
|
|
1802
|
+
class TextTransitionManager {
|
|
1377
1803
|
/**
|
|
1378
|
-
*
|
|
1804
|
+
* 텍스트 전환 슬라이더 컨테이너 생성
|
|
1379
1805
|
*/
|
|
1380
|
-
static
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
//
|
|
1384
|
-
const
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1806
|
+
static createTextTransitionContainer(slot, advertisements, options, trackEventCallback) {
|
|
1807
|
+
const sliderWrapper = document.createElement('div');
|
|
1808
|
+
sliderWrapper.className = 'adstage-fade-slider-wrapper';
|
|
1809
|
+
// 래퍼 스타일 설정
|
|
1810
|
+
const containerStyles = {
|
|
1811
|
+
position: 'relative',
|
|
1812
|
+
overflow: 'hidden',
|
|
1813
|
+
display: 'inline-block',
|
|
1814
|
+
};
|
|
1815
|
+
// 사용자가 크기를 지정한 경우
|
|
1816
|
+
if (slot.width && slot.width !== 0) {
|
|
1817
|
+
let width;
|
|
1818
|
+
if (typeof slot.width === 'string') {
|
|
1819
|
+
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
1388
1820
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
// 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
|
|
1392
|
-
const sessionKey = `adstage_viewable_${key}`;
|
|
1393
|
-
const sessionViewable = sessionStorage.getItem(sessionKey);
|
|
1394
|
-
if (sessionViewable) {
|
|
1395
|
-
const sessionTime = parseInt(sessionViewable, 10);
|
|
1396
|
-
if (!isNaN(sessionTime) && (now - sessionTime) < ViewableEventTracker.VIEWABLE_COOLDOWN) {
|
|
1397
|
-
if (debug) {
|
|
1398
|
-
console.log(`Session-based duplicate viewable blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ViewableEventTracker.VIEWABLE_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
|
|
1399
|
-
}
|
|
1400
|
-
// 메모리에도 기록하여 이후 요청 최적화
|
|
1401
|
-
ViewableEventTracker.viewableTracker.set(key, sessionTime);
|
|
1402
|
-
return true;
|
|
1821
|
+
else {
|
|
1822
|
+
width = `${slot.width}px`;
|
|
1403
1823
|
}
|
|
1824
|
+
containerStyles.width = width;
|
|
1404
1825
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
console.log(`🎯 New viewable event allowed for ad ${adId} in slot ${slotId}`);
|
|
1410
|
-
}
|
|
1411
|
-
// 오래된 세션 스토리지 데이터 정리 (선택적)
|
|
1412
|
-
ViewableEventTracker.cleanupOldViewables();
|
|
1413
|
-
return false;
|
|
1414
|
-
}
|
|
1415
|
-
/**
|
|
1416
|
-
* 오래된 viewable 추적 데이터 정리
|
|
1417
|
-
*/
|
|
1418
|
-
static cleanupOldViewables() {
|
|
1419
|
-
const now = Date.now();
|
|
1420
|
-
const cleanupThreshold = ViewableEventTracker.VIEWABLE_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
|
|
1421
|
-
// 세션 스토리지 정리
|
|
1422
|
-
for (let i = 0; i < sessionStorage.length; i++) {
|
|
1423
|
-
const key = sessionStorage.key(i);
|
|
1424
|
-
if (key && key.startsWith('adstage_viewable_')) {
|
|
1425
|
-
const timestamp = sessionStorage.getItem(key);
|
|
1426
|
-
if (timestamp) {
|
|
1427
|
-
const time = parseInt(timestamp, 10);
|
|
1428
|
-
if (!isNaN(time) && (now - time) > cleanupThreshold) {
|
|
1429
|
-
sessionStorage.removeItem(key);
|
|
1430
|
-
i--; // 인덱스 조정
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
1826
|
+
if (slot.height && slot.height !== 0) {
|
|
1827
|
+
let height;
|
|
1828
|
+
if (typeof slot.height === 'string') {
|
|
1829
|
+
height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
|
|
1433
1830
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
for (const [key, timestamp] of ViewableEventTracker.viewableTracker.entries()) {
|
|
1437
|
-
if ((now - timestamp) > cleanupThreshold) {
|
|
1438
|
-
ViewableEventTracker.viewableTracker.delete(key);
|
|
1831
|
+
else {
|
|
1832
|
+
height = `${slot.height}px`;
|
|
1439
1833
|
}
|
|
1834
|
+
containerStyles.height = height;
|
|
1440
1835
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1836
|
+
// 스타일 적용
|
|
1837
|
+
Object.entries(containerStyles).forEach(([key, value]) => {
|
|
1838
|
+
sliderWrapper.style.setProperty(key, value);
|
|
1839
|
+
});
|
|
1840
|
+
// 슬라이드 컨테이너
|
|
1841
|
+
const slideContainer = document.createElement('div');
|
|
1842
|
+
slideContainer.className = 'adstage-fade-slide-container';
|
|
1843
|
+
slideContainer.style.cssText = `
|
|
1844
|
+
position: relative;
|
|
1845
|
+
width: 100%;
|
|
1846
|
+
height: 100%;
|
|
1847
|
+
`;
|
|
1848
|
+
// 크기 측정을 위한 임시 컨테이너 (자동 크기 계산이 필요한 경우)
|
|
1849
|
+
let measureContainer = null;
|
|
1850
|
+
const needsWidthMeasurement = !slot.width || slot.width === 0;
|
|
1851
|
+
const needsHeightMeasurement = !slot.height || slot.height === 0;
|
|
1852
|
+
if (needsWidthMeasurement || needsHeightMeasurement) {
|
|
1853
|
+
measureContainer = document.createElement('div');
|
|
1854
|
+
measureContainer.style.cssText = `
|
|
1855
|
+
position: absolute;
|
|
1856
|
+
visibility: hidden;
|
|
1857
|
+
white-space: nowrap;
|
|
1858
|
+
top: -9999px;
|
|
1859
|
+
left: -9999px;
|
|
1860
|
+
`;
|
|
1861
|
+
// width가 설정되어 있으면 측정 컨테이너에도 적용
|
|
1862
|
+
if (!needsWidthMeasurement && slot.width) {
|
|
1863
|
+
let width;
|
|
1864
|
+
if (typeof slot.width === 'string') {
|
|
1865
|
+
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
width = `${slot.width}px`;
|
|
1869
|
+
}
|
|
1870
|
+
measureContainer.style.width = width;
|
|
1871
|
+
measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
|
|
1872
|
+
}
|
|
1873
|
+
document.body.appendChild(measureContainer);
|
|
1874
|
+
let maxWidth = 0;
|
|
1875
|
+
let maxHeight = 0;
|
|
1876
|
+
// 모든 광고의 크기를 측정하여 최대 크기 찾기
|
|
1877
|
+
advertisements.forEach(ad => {
|
|
1878
|
+
const measureAdElement = AdRendererFactory.render(ad, slot, trackEventCallback);
|
|
1879
|
+
measureContainer.appendChild(measureAdElement);
|
|
1880
|
+
const rect = measureAdElement.getBoundingClientRect();
|
|
1881
|
+
if (rect.width > maxWidth)
|
|
1882
|
+
maxWidth = rect.width;
|
|
1883
|
+
if (rect.height > maxHeight)
|
|
1884
|
+
maxHeight = rect.height;
|
|
1885
|
+
// 측정 후 요소 제거
|
|
1886
|
+
measureContainer.removeChild(measureAdElement);
|
|
1887
|
+
});
|
|
1888
|
+
// 측정된 최대 크기로 래퍼 크기 설정
|
|
1889
|
+
if (needsWidthMeasurement && maxWidth > 0) {
|
|
1890
|
+
sliderWrapper.style.width = `${maxWidth}px`;
|
|
1891
|
+
}
|
|
1892
|
+
if (needsHeightMeasurement && maxHeight > 0) {
|
|
1893
|
+
sliderWrapper.style.height = `${maxHeight}px`;
|
|
1894
|
+
}
|
|
1895
|
+
// 측정 컨테이너 제거
|
|
1896
|
+
document.body.removeChild(measureContainer);
|
|
1897
|
+
}
|
|
1898
|
+
// 각 광고를 슬라이드로 생성
|
|
1899
|
+
const slideElements = [];
|
|
1900
|
+
advertisements.forEach((ad, index) => {
|
|
1901
|
+
const slideElement = document.createElement('div');
|
|
1902
|
+
slideElement.className = 'adstage-fade-slide';
|
|
1903
|
+
slideElement.style.cssText = `
|
|
1904
|
+
position: absolute;
|
|
1905
|
+
top: 0;
|
|
1906
|
+
left: 0;
|
|
1907
|
+
width: 100%;
|
|
1908
|
+
height: 100%;
|
|
1909
|
+
display: flex;
|
|
1910
|
+
align-items: center;
|
|
1911
|
+
justify-content: center;
|
|
1912
|
+
opacity: ${index === 0 ? '1' : '0'};
|
|
1913
|
+
transform: translateY(${index === 0 ? '0' : '20px'});
|
|
1914
|
+
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
1915
|
+
z-index: ${index === 0 ? '2' : '1'};
|
|
1916
|
+
`;
|
|
1917
|
+
// 광고 렌더링
|
|
1918
|
+
const adElement = AdRendererFactory.render(ad, slot, trackEventCallback);
|
|
1919
|
+
slideElement.appendChild(adElement);
|
|
1920
|
+
slideContainer.appendChild(slideElement);
|
|
1921
|
+
slideElements.push(slideElement);
|
|
1922
|
+
});
|
|
1923
|
+
// 슬라이더 상태 관리
|
|
1924
|
+
let currentSlide = 0;
|
|
1925
|
+
const totalSlides = advertisements.length;
|
|
1926
|
+
const autoSlideInterval = (options?.autoSlideInterval || 4) * 1000; // 기본 4초 (페이드는 조금 더 길게)
|
|
1927
|
+
// 슬라이드 이동 함수 (페이드 효과)
|
|
1928
|
+
const moveToSlide = (index) => {
|
|
1929
|
+
if (index >= totalSlides) {
|
|
1930
|
+
index = 0; // 무한 루프
|
|
1931
|
+
}
|
|
1932
|
+
else if (index < 0) {
|
|
1933
|
+
index = totalSlides - 1;
|
|
1934
|
+
}
|
|
1935
|
+
const previousSlide = slideElements[currentSlide];
|
|
1936
|
+
const nextSlide = slideElements[index];
|
|
1937
|
+
// 이전 슬라이드 페이드 아웃 (아래로)
|
|
1938
|
+
previousSlide.style.opacity = '0';
|
|
1939
|
+
previousSlide.style.transform = 'translateY(-20px)';
|
|
1940
|
+
previousSlide.style.zIndex = '1';
|
|
1941
|
+
// 다음 슬라이드 페이드 인 (위에서)
|
|
1942
|
+
nextSlide.style.opacity = '1';
|
|
1943
|
+
nextSlide.style.transform = 'translateY(0)';
|
|
1944
|
+
nextSlide.style.zIndex = '2';
|
|
1945
|
+
// 다른 슬라이드들은 숨김
|
|
1946
|
+
slideElements.forEach((slide, i) => {
|
|
1947
|
+
if (i !== index && i !== currentSlide) {
|
|
1948
|
+
slide.style.opacity = '0';
|
|
1949
|
+
slide.style.transform = 'translateY(20px)';
|
|
1950
|
+
slide.style.zIndex = '1';
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1953
|
+
currentSlide = index;
|
|
1954
|
+
// 현재 슬라이드의 광고에 대해 노출 이벤트 추적
|
|
1955
|
+
if (currentSlide > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
|
|
1956
|
+
trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.VIEWABLE);
|
|
1957
|
+
}
|
|
1532
1958
|
};
|
|
1959
|
+
// 자동 슬라이드
|
|
1960
|
+
let autoSlideTimer = setInterval(() => {
|
|
1961
|
+
const nextIndex = currentSlide + 1;
|
|
1962
|
+
moveToSlide(nextIndex);
|
|
1963
|
+
}, autoSlideInterval);
|
|
1964
|
+
// 마우스 호버 시 자동 슬라이드 일시정지
|
|
1965
|
+
sliderWrapper.addEventListener('mouseenter', () => {
|
|
1966
|
+
clearInterval(autoSlideTimer);
|
|
1967
|
+
});
|
|
1968
|
+
sliderWrapper.addEventListener('mouseleave', () => {
|
|
1969
|
+
autoSlideTimer = setInterval(() => {
|
|
1970
|
+
const nextIndex = currentSlide + 1;
|
|
1971
|
+
moveToSlide(nextIndex);
|
|
1972
|
+
}, autoSlideInterval);
|
|
1973
|
+
});
|
|
1974
|
+
// 터치 제스처 지원
|
|
1975
|
+
TextTransitionManager.addTouchSupport(sliderWrapper, moveToSlide, () => currentSlide, totalSlides);
|
|
1976
|
+
// 요소들 조립
|
|
1977
|
+
sliderWrapper.appendChild(slideContainer);
|
|
1978
|
+
return sliderWrapper;
|
|
1533
1979
|
}
|
|
1534
1980
|
/**
|
|
1535
|
-
*
|
|
1981
|
+
* 터치 제스처 지원 추가
|
|
1536
1982
|
*/
|
|
1537
|
-
static
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1983
|
+
static addTouchSupport(container, moveToSlide, getCurrentSlide, totalSlides) {
|
|
1984
|
+
let startX = 0;
|
|
1985
|
+
let startY = 0;
|
|
1986
|
+
let isDragging = false;
|
|
1987
|
+
container.addEventListener('touchstart', (e) => {
|
|
1988
|
+
startX = e.touches[0].clientX;
|
|
1989
|
+
startY = e.touches[0].clientY;
|
|
1990
|
+
isDragging = true;
|
|
1991
|
+
});
|
|
1992
|
+
container.addEventListener('touchmove', (e) => {
|
|
1993
|
+
if (!isDragging)
|
|
1994
|
+
return;
|
|
1995
|
+
e.preventDefault();
|
|
1996
|
+
});
|
|
1997
|
+
container.addEventListener('touchend', (e) => {
|
|
1998
|
+
if (!isDragging)
|
|
1999
|
+
return;
|
|
2000
|
+
isDragging = false;
|
|
2001
|
+
const endX = e.changedTouches[0].clientX;
|
|
2002
|
+
const endY = e.changedTouches[0].clientY;
|
|
2003
|
+
const diffX = startX - endX;
|
|
2004
|
+
const diffY = startY - endY;
|
|
2005
|
+
// 가로 스와이프가 세로 스와이프보다 클 때만 처리
|
|
2006
|
+
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
|
2007
|
+
const currentSlide = getCurrentSlide();
|
|
2008
|
+
if (diffX > 0) {
|
|
2009
|
+
// 왼쪽으로 스와이프 (다음 슬라이드)
|
|
2010
|
+
moveToSlide(currentSlide + 1);
|
|
2011
|
+
}
|
|
2012
|
+
else {
|
|
2013
|
+
// 오른쪽으로 스와이프 (이전 슬라이드)
|
|
2014
|
+
moveToSlide(currentSlide - 1);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
1544
2018
|
}
|
|
1545
2019
|
}
|
|
1546
2020
|
|
|
1547
2021
|
/**
|
|
1548
|
-
*
|
|
1549
|
-
*
|
|
2022
|
+
* AdRenderer - 광고 렌더링 전용 클래스
|
|
2023
|
+
* AdsModule에서 렌더링 관련 기능을 분리
|
|
1550
2024
|
*/
|
|
1551
|
-
class
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
static create(apiKey, options) {
|
|
1556
|
-
if (!apiKey) {
|
|
1557
|
-
throw new Error('API key is required');
|
|
1558
|
-
}
|
|
1559
|
-
const headers = {
|
|
1560
|
-
'x-api-key': apiKey,
|
|
1561
|
-
'Content-Type': options?.contentType || 'application/json'
|
|
1562
|
-
};
|
|
1563
|
-
// User-Agent는 이벤트 추적에서 실제로 사용됨
|
|
1564
|
-
if (typeof navigator !== 'undefined') {
|
|
1565
|
-
headers['User-Agent'] = options?.userAgent || navigator.userAgent;
|
|
1566
|
-
}
|
|
1567
|
-
// X-Current-URL은 현재 서버에서 사용하지 않으므로 제거
|
|
1568
|
-
// 필요시 이벤트 데이터 body에 포함
|
|
1569
|
-
return headers;
|
|
2025
|
+
class AdRenderer {
|
|
2026
|
+
constructor(debug = false, advertisementEventTracker) {
|
|
2027
|
+
this.debug = debug;
|
|
2028
|
+
this.advertisementEventTracker = advertisementEventTracker || null;
|
|
1570
2029
|
}
|
|
1571
2030
|
/**
|
|
1572
|
-
*
|
|
1573
|
-
* User-Agent는 서버에서 실제로 사용됨
|
|
2031
|
+
* Placeholder(슬롯 컨테이너) 생성
|
|
1574
2032
|
*/
|
|
1575
|
-
|
|
1576
|
-
const
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
2033
|
+
createPlaceholder(container, slotId, type, options, _config) {
|
|
2034
|
+
const adElement = document.createElement('div');
|
|
2035
|
+
adElement.id = slotId;
|
|
2036
|
+
adElement.className = `adstage-slot adstage-${String(type).toLowerCase()}`;
|
|
2037
|
+
adElement.setAttribute('data-adstage-container', 'true');
|
|
2038
|
+
adElement.setAttribute('data-adstage-type', String(type));
|
|
2039
|
+
adElement.setAttribute('data-adstage-slot', slotId);
|
|
2040
|
+
const { width, height } = this.calculateAdSize(container, type, options, _config) || {
|
|
2041
|
+
width: '100%',
|
|
2042
|
+
height: '250px'
|
|
2043
|
+
};
|
|
2044
|
+
adElement.style.width = width;
|
|
2045
|
+
adElement.style.height = height;
|
|
2046
|
+
adElement.style.border = '1px dashed #ccc';
|
|
2047
|
+
adElement.style.display = 'flex';
|
|
2048
|
+
adElement.style.alignItems = 'center';
|
|
2049
|
+
adElement.style.justifyContent = 'center';
|
|
2050
|
+
adElement.style.backgroundColor = '#f9f9f9';
|
|
2051
|
+
adElement.style.color = '#666';
|
|
2052
|
+
adElement.innerHTML = `<span>Loading ${type} ad...</span>`;
|
|
2053
|
+
container.appendChild(adElement);
|
|
2054
|
+
if (this.debug) {
|
|
2055
|
+
console.log(`📦 Placeholder created for slot: ${slotId} (${width} x ${height})`);
|
|
1580
2056
|
}
|
|
1581
|
-
// 다른 정보들은 HTTP 헤더가 아닌 이벤트 데이터 body에 포함하는 것이 적절
|
|
1582
|
-
// (currentUrl, referrer 등은 POST body로 전송)
|
|
1583
|
-
return baseHeaders;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* 광고 이벤트 추적 관리 클래스
|
|
1589
|
-
* - 광고 전용 이벤트 추적 및 전송
|
|
1590
|
-
* - Viewable 이벤트 중복 방지 통합
|
|
1591
|
-
* - 광고 서버 API 통신
|
|
1592
|
-
*/
|
|
1593
|
-
class AdvertisementEventTracker {
|
|
1594
|
-
constructor(baseUrl, apiKey, debug, slots) {
|
|
1595
|
-
this.baseUrl = baseUrl;
|
|
1596
|
-
this.apiKey = apiKey;
|
|
1597
|
-
this.debug = debug;
|
|
1598
|
-
this.slots = slots;
|
|
1599
2057
|
}
|
|
1600
2058
|
/**
|
|
1601
|
-
*
|
|
2059
|
+
* 여러 광고의 최적 컨테이너 크기 계산 (동적 크기 조정)
|
|
1602
2060
|
*/
|
|
1603
|
-
async
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
const deviceInfo = DeviceInfoCollector.collectDeviceInfo();
|
|
1610
|
-
// 광고 이벤트 데이터 구성 (DTO 구조에 맞춤)
|
|
1611
|
-
const eventData = {
|
|
1612
|
-
// 서버에서 자동 설정: orgId, advertisementId, action
|
|
1613
|
-
// 하지만 DTO 검증을 위해 임시값 제공
|
|
1614
|
-
orgId: 'temp', // 서버에서 API 키로부터 덮어씀
|
|
1615
|
-
advertisementId: adId, // 서버에서 URL 파라미터로부터 덮어씀
|
|
1616
|
-
action: eventType, // 서버에서 URL 파라미터로부터 덮어씀
|
|
1617
|
-
// 필수 필드들
|
|
1618
|
-
adType: slot?.adType || 'BANNER',
|
|
1619
|
-
platform: deviceInfo.platform,
|
|
1620
|
-
// 디바이스 정보 - 스키마에 맞게 최상위 레벨로 추출
|
|
1621
|
-
deviceId: deviceInfo.deviceId,
|
|
1622
|
-
osVersion: deviceInfo.osVersion,
|
|
1623
|
-
deviceModel: deviceInfo.deviceModel,
|
|
1624
|
-
appVersion: deviceInfo.appVersion,
|
|
1625
|
-
sdkVersion: deviceInfo.sdkVersion,
|
|
1626
|
-
language: deviceInfo.language,
|
|
1627
|
-
country: deviceInfo.country,
|
|
1628
|
-
timezone: deviceInfo.timezone,
|
|
1629
|
-
viewportWidth: deviceInfo.viewportWidth,
|
|
1630
|
-
viewportHeight: deviceInfo.viewportHeight,
|
|
1631
|
-
screenWidth: deviceInfo.screenWidth,
|
|
1632
|
-
screenHeight: deviceInfo.screenHeight,
|
|
1633
|
-
connectionType: deviceInfo.connectionType,
|
|
1634
|
-
// 페이지 및 슬롯 정보
|
|
1635
|
-
pageUrl: DOMUtils.getPageInfo().url,
|
|
1636
|
-
pageTitle: DOMUtils.getPageInfo().title,
|
|
1637
|
-
referrer: DOMUtils.getPageInfo().referrer,
|
|
1638
|
-
slotId,
|
|
1639
|
-
slotPosition: DeviceInfoCollector.getSlotPosition(slot?.containerId || ''),
|
|
1640
|
-
slotWidth: AdvertisementEventTracker.parseNumericValue(slot?.width),
|
|
1641
|
-
slotHeight: AdvertisementEventTracker.parseNumericValue(slot?.height),
|
|
1642
|
-
sessionId: deviceInfo.sessionId,
|
|
1643
|
-
// 성능 메트릭
|
|
1644
|
-
pageLoadTime: performance.now(),
|
|
1645
|
-
// 추가 메타데이터
|
|
1646
|
-
metadata: {
|
|
1647
|
-
eventType,
|
|
1648
|
-
sdkVersion: '1.0.0',
|
|
1649
|
-
timestamp: Date.now(),
|
|
1650
|
-
},
|
|
1651
|
-
// viewable 관련 추가 데이터 (DTO 필드명과 매칭)
|
|
1652
|
-
...(additionalData?.viewabilityMetrics && {
|
|
1653
|
-
isViewable: additionalData.viewabilityMetrics.isViewable,
|
|
1654
|
-
exposureTime: additionalData.viewabilityMetrics.exposureTime,
|
|
1655
|
-
maxVisibilityRatio: additionalData.viewabilityMetrics.maxVisibilityRatio,
|
|
1656
|
-
firstViewableTime: additionalData.viewabilityMetrics.firstViewableTime,
|
|
1657
|
-
// IAB 표준 준수 여부
|
|
1658
|
-
iabCompliant: additionalData.viewabilityMetrics.isViewable,
|
|
1659
|
-
}),
|
|
1660
|
-
// fraud 관련 데이터 (DTO 필드명과 매칭)
|
|
1661
|
-
...(additionalData?.fraudScore !== undefined && {
|
|
1662
|
-
fraudScore: additionalData.fraudScore,
|
|
1663
|
-
fraudReasons: additionalData.fraudReasons,
|
|
1664
|
-
riskLevel: additionalData.riskLevel,
|
|
1665
|
-
}),
|
|
2061
|
+
async calculateOptimalContainerSize(advertisements, containerWidth, adType) {
|
|
2062
|
+
if (!advertisements.length || adType !== AdType.BANNER) {
|
|
2063
|
+
return {
|
|
2064
|
+
width: '100%',
|
|
2065
|
+
height: this.getDefaultHeightForAdType(adType) || '250px',
|
|
2066
|
+
aspectRatio: 16 / 9
|
|
1666
2067
|
};
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2068
|
+
}
|
|
2069
|
+
try {
|
|
2070
|
+
const imageDimensions = await Promise.allSettled(advertisements
|
|
2071
|
+
.filter(ad => ad.imageUrl)
|
|
2072
|
+
.map(ad => this.loadImageDimensions(ad.imageUrl)));
|
|
2073
|
+
const validDimensions = imageDimensions
|
|
2074
|
+
.filter((result) => result.status === 'fulfilled')
|
|
2075
|
+
.map(result => result.value);
|
|
2076
|
+
if (validDimensions.length === 0) {
|
|
2077
|
+
return {
|
|
2078
|
+
width: '100%',
|
|
2079
|
+
height: this.getDefaultHeightForAdType(adType) || '250px',
|
|
2080
|
+
aspectRatio: 16 / 9
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
const strategy = this.selectOptimalSizeStrategy(validDimensions);
|
|
2084
|
+
const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
|
|
1672
2085
|
if (this.debug) {
|
|
1673
|
-
console.log(
|
|
2086
|
+
console.log(`📐 Optimal container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
|
|
1674
2087
|
}
|
|
2088
|
+
return {
|
|
2089
|
+
width: '100%',
|
|
2090
|
+
height: `${optimalHeight}px`,
|
|
2091
|
+
aspectRatio: containerWidth / optimalHeight
|
|
2092
|
+
};
|
|
1675
2093
|
}
|
|
1676
2094
|
catch (error) {
|
|
1677
|
-
console.
|
|
2095
|
+
console.warn('Failed to calculate optimal size, using defaults:', error);
|
|
2096
|
+
return {
|
|
2097
|
+
width: '100%',
|
|
2098
|
+
height: this.getDefaultHeightForAdType(adType) || '250px',
|
|
2099
|
+
aspectRatio: 16 / 9
|
|
2100
|
+
};
|
|
1678
2101
|
}
|
|
1679
2102
|
}
|
|
1680
2103
|
/**
|
|
1681
|
-
* 크기
|
|
2104
|
+
* 최적 크기 조정 전략 선택
|
|
1682
2105
|
*/
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
2106
|
+
selectOptimalSizeStrategy(dimensions) {
|
|
2107
|
+
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
2108
|
+
const ratioGroups = new Map();
|
|
2109
|
+
aspectRatios.forEach(ratio => {
|
|
2110
|
+
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
2111
|
+
const key = roundedRatio.toString();
|
|
2112
|
+
ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
|
|
2113
|
+
});
|
|
2114
|
+
const maxGroup = Math.max(...ratioGroups.values());
|
|
2115
|
+
const totalImages = dimensions.length;
|
|
2116
|
+
if (maxGroup / totalImages >= 0.7) {
|
|
2117
|
+
return 'dominant';
|
|
1691
2118
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
/**
|
|
1697
|
-
* IAB 표준 준수 viewable impression 측정
|
|
1698
|
-
*/
|
|
1699
|
-
// 광고 타입별 IAB 표준 설정
|
|
1700
|
-
const VIEWABILITY_STANDARDS = {
|
|
1701
|
-
BANNER: {
|
|
1702
|
-
threshold: 0.5,
|
|
1703
|
-
minDuration: 1000,
|
|
1704
|
-
maxMeasureTime: 30000
|
|
1705
|
-
},
|
|
1706
|
-
VIDEO: {
|
|
1707
|
-
threshold: 0.5,
|
|
1708
|
-
minDuration: 2000,
|
|
1709
|
-
maxMeasureTime: 60000
|
|
1710
|
-
},
|
|
1711
|
-
NATIVE: {
|
|
1712
|
-
threshold: 0.5,
|
|
1713
|
-
minDuration: 1000,
|
|
1714
|
-
maxMeasureTime: 30000
|
|
1715
|
-
},
|
|
1716
|
-
INTERSTITIAL: {
|
|
1717
|
-
threshold: 0.5,
|
|
1718
|
-
minDuration: 1000,
|
|
1719
|
-
maxMeasureTime: 10000
|
|
1720
|
-
},
|
|
1721
|
-
TEXT: {
|
|
1722
|
-
threshold: 0.5,
|
|
1723
|
-
minDuration: 1000,
|
|
1724
|
-
maxMeasureTime: 30000
|
|
1725
|
-
},
|
|
1726
|
-
POPUP: {
|
|
1727
|
-
threshold: 0.5,
|
|
1728
|
-
minDuration: 1000,
|
|
1729
|
-
maxMeasureTime: 10000
|
|
1730
|
-
}
|
|
1731
|
-
};
|
|
1732
|
-
class ViewabilityTracker {
|
|
1733
|
-
constructor(element, adType, onViewable) {
|
|
1734
|
-
this.observer = null;
|
|
1735
|
-
this.viewabilityTimer = null;
|
|
1736
|
-
this.maxVisibilityTimer = null;
|
|
1737
|
-
this.startTime = 0;
|
|
1738
|
-
this.maxVisibilityRatio = 0;
|
|
1739
|
-
this.firstViewableTime = null;
|
|
1740
|
-
this.isViewableAchieved = false;
|
|
1741
|
-
this.element = element;
|
|
1742
|
-
this.config = VIEWABILITY_STANDARDS[adType] || VIEWABILITY_STANDARDS.BANNER;
|
|
1743
|
-
this.onViewableCallback = onViewable;
|
|
1744
|
-
this.startTime = performance.now();
|
|
1745
|
-
this.initIntersectionObserver();
|
|
1746
|
-
this.initMaxMeasureTimer();
|
|
1747
|
-
}
|
|
1748
|
-
initIntersectionObserver() {
|
|
1749
|
-
// IntersectionObserver 지원 확인
|
|
1750
|
-
if (!('IntersectionObserver' in window)) {
|
|
1751
|
-
console.warn('IntersectionObserver not supported, viewability tracking disabled');
|
|
1752
|
-
return;
|
|
2119
|
+
const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
|
|
2120
|
+
const standardCount = aspectRatios.filter(ratio => standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)).length;
|
|
2121
|
+
if (standardCount / totalImages >= 0.5) {
|
|
2122
|
+
return 'common';
|
|
1753
2123
|
}
|
|
1754
|
-
|
|
1755
|
-
threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0],
|
|
1756
|
-
rootMargin: '0px'
|
|
1757
|
-
});
|
|
1758
|
-
this.observer.observe(this.element);
|
|
2124
|
+
return 'average';
|
|
1759
2125
|
}
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2126
|
+
/**
|
|
2127
|
+
* 전략에 따른 최적 높이 계산
|
|
2128
|
+
*/
|
|
2129
|
+
calculateOptimalHeight(dimensions, containerWidth, strategy) {
|
|
2130
|
+
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
2131
|
+
switch (strategy) {
|
|
2132
|
+
case 'dominant': {
|
|
2133
|
+
const ratioGroups = new Map();
|
|
2134
|
+
aspectRatios.forEach(ratio => {
|
|
2135
|
+
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
2136
|
+
const key = roundedRatio.toString();
|
|
2137
|
+
const existing = ratioGroups.get(key);
|
|
2138
|
+
if (existing) {
|
|
2139
|
+
existing.count++;
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
const dominantGroup = Array.from(ratioGroups.values()).reduce((max, current) => current.count > max.count ? current : max);
|
|
2146
|
+
return Math.round(containerWidth / dominantGroup.ratio);
|
|
1769
2147
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
2148
|
+
case 'common': {
|
|
2149
|
+
const standardRatios = [
|
|
2150
|
+
{ ratio: 16 / 9, name: '16:9' },
|
|
2151
|
+
{ ratio: 4 / 3, name: '4:3' },
|
|
2152
|
+
{ ratio: 1 / 1, name: '1:1' },
|
|
2153
|
+
{ ratio: 3 / 2, name: '3:2' }
|
|
2154
|
+
];
|
|
2155
|
+
const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
2156
|
+
const bestStandard = standardRatios.reduce((best, current) => Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best);
|
|
2157
|
+
if (this.debug) {
|
|
2158
|
+
console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
|
|
2159
|
+
}
|
|
2160
|
+
return Math.round(containerWidth / bestStandard.ratio);
|
|
2161
|
+
}
|
|
2162
|
+
case 'average':
|
|
2163
|
+
default: {
|
|
2164
|
+
const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
2165
|
+
return Math.round(containerWidth / averageRatio);
|
|
1772
2166
|
}
|
|
1773
|
-
});
|
|
1774
|
-
}
|
|
1775
|
-
isDocumentVisible() {
|
|
1776
|
-
// 단순한 문서 가시성 확인
|
|
1777
|
-
return !document.hidden && document.visibilityState === 'visible';
|
|
1778
|
-
}
|
|
1779
|
-
startViewabilityTimer() {
|
|
1780
|
-
if (this.viewabilityTimer || this.isViewableAchieved)
|
|
1781
|
-
return;
|
|
1782
|
-
if (this.firstViewableTime === null) {
|
|
1783
|
-
this.firstViewableTime = performance.now();
|
|
1784
2167
|
}
|
|
1785
|
-
this.viewabilityTimer = setTimeout(() => {
|
|
1786
|
-
this.onViewabilityAchieved();
|
|
1787
|
-
}, this.config.minDuration);
|
|
1788
2168
|
}
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
2169
|
+
/**
|
|
2170
|
+
* 배너 광고를 위한 컨테이너 최적화
|
|
2171
|
+
*/
|
|
2172
|
+
async optimizeContainerForBannerAds(slot, advertisements) {
|
|
2173
|
+
try {
|
|
2174
|
+
const container = document.getElementById(slot.containerId);
|
|
2175
|
+
const adElement = document.getElementById(slot.id);
|
|
2176
|
+
if (!container || !adElement)
|
|
2177
|
+
return;
|
|
2178
|
+
const containerWidth = container.getBoundingClientRect().width || 300;
|
|
2179
|
+
const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth, slot.adType);
|
|
2180
|
+
adElement.style.height = optimalSize.height;
|
|
2181
|
+
slot.optimizedHeight = optimalSize.height;
|
|
2182
|
+
slot.aspectRatio = optimalSize.aspectRatio;
|
|
2183
|
+
if (this.debug) {
|
|
2184
|
+
console.log(`🔧 Container optimized for ${advertisements.length} banner ads: ${optimalSize.height}`);
|
|
2185
|
+
}
|
|
1793
2186
|
}
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
// 최대 측정 시간 후 자동 종료
|
|
1797
|
-
this.maxVisibilityTimer = setTimeout(() => {
|
|
1798
|
-
this.destroy();
|
|
1799
|
-
}, this.config.maxMeasureTime);
|
|
1800
|
-
}
|
|
1801
|
-
onViewabilityAchieved() {
|
|
1802
|
-
if (this.isViewableAchieved)
|
|
1803
|
-
return;
|
|
1804
|
-
this.isViewableAchieved = true;
|
|
1805
|
-
const metrics = this.calculateMetrics();
|
|
1806
|
-
if (this.onViewableCallback) {
|
|
1807
|
-
this.onViewableCallback(metrics);
|
|
2187
|
+
catch (error) {
|
|
2188
|
+
console.warn('Container optimization failed, using default size:', error);
|
|
1808
2189
|
}
|
|
1809
2190
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2191
|
+
/**
|
|
2192
|
+
* 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
|
|
2193
|
+
*/
|
|
2194
|
+
async renderAdSlider(slot, advertisements) {
|
|
2195
|
+
const container = document.getElementById(slot.containerId);
|
|
2196
|
+
if (!container) {
|
|
2197
|
+
throw new Error(`Container not found: ${slot.containerId}`);
|
|
2198
|
+
}
|
|
2199
|
+
const trackEventCallback = async (adId, slotId, eventType) => {
|
|
2200
|
+
if (eventType === AdEventType.VIEWABLE) {
|
|
2201
|
+
if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this.debug)) {
|
|
2202
|
+
if (this.debug) {
|
|
2203
|
+
console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
|
|
2204
|
+
}
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (this.debug) {
|
|
2208
|
+
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
if (this.advertisementEventTracker) {
|
|
2212
|
+
try {
|
|
2213
|
+
await this.advertisementEventTracker.trackAdvertisementEvent(adId, slotId, eventType, {});
|
|
2214
|
+
if (this.debug) {
|
|
2215
|
+
console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
catch (error) {
|
|
2219
|
+
if (this.debug) {
|
|
2220
|
+
console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
1821
2224
|
};
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
2225
|
+
let sliderElement;
|
|
2226
|
+
const optimizedSliderOptions = {
|
|
2227
|
+
autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
|
|
2228
|
+
...slot.config,
|
|
2229
|
+
optimizedHeight: slot.optimizedHeight,
|
|
2230
|
+
aspectRatio: slot.aspectRatio
|
|
2231
|
+
};
|
|
2232
|
+
if (slot.adType === AdType.TEXT) {
|
|
2233
|
+
sliderElement = TextTransitionManager.createTextTransitionContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
|
|
2234
|
+
if (this.debug) {
|
|
2235
|
+
console.log(`✨ Text transition created for TEXT slot: ${slot.id} with ${advertisements.length} ads`);
|
|
2236
|
+
}
|
|
1831
2237
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
this.
|
|
2238
|
+
else {
|
|
2239
|
+
sliderElement = CarouselSliderManager.createSliderContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
|
|
2240
|
+
if (this.debug) {
|
|
2241
|
+
console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads (optimized: ${slot.optimizedHeight || 'default'})`);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
container.innerHTML = '';
|
|
2245
|
+
container.appendChild(sliderElement);
|
|
2246
|
+
if (this.debug) {
|
|
2247
|
+
console.log(`� Slider uses manual VIEWABLE event tracking for ${advertisements.length} ads`);
|
|
1835
2248
|
}
|
|
1836
2249
|
}
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
this.keyboardEvents = 0;
|
|
1847
|
-
this.scrollEvents = 0;
|
|
1848
|
-
this.startTime = Date.now();
|
|
1849
|
-
this.initBasicTracking();
|
|
1850
|
-
}
|
|
1851
|
-
initBasicTracking() {
|
|
1852
|
-
// 기본적인 사용자 상호작용 추적
|
|
1853
|
-
document.addEventListener('mousemove', () => this.mouseEvents++, { passive: true });
|
|
1854
|
-
document.addEventListener('keydown', () => this.keyboardEvents++, { passive: true });
|
|
1855
|
-
document.addEventListener('scroll', () => this.scrollEvents++, { passive: true });
|
|
2250
|
+
/**
|
|
2251
|
+
* 광고 렌더링 (단일 광고용)
|
|
2252
|
+
*/
|
|
2253
|
+
async renderAd(slot) {
|
|
2254
|
+
if (!slot.advertisement) {
|
|
2255
|
+
throw new Error('No advertisement to render');
|
|
2256
|
+
}
|
|
2257
|
+
await this.renderAdElement(slot, slot.advertisement);
|
|
2258
|
+
slot.isLoaded = true;
|
|
1856
2259
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
2260
|
+
/**
|
|
2261
|
+
* 광고 요소 렌더링 (기본 구현)
|
|
2262
|
+
*/
|
|
2263
|
+
async renderAdElement(slot, ad) {
|
|
2264
|
+
const container = document.getElementById(slot.containerId);
|
|
2265
|
+
if (!container)
|
|
2266
|
+
return;
|
|
2267
|
+
const adElement = document.createElement('div');
|
|
2268
|
+
adElement.className = 'adstage-ad';
|
|
2269
|
+
const optimizedHeight = slot.optimizedHeight;
|
|
2270
|
+
const containerElement = container.parentElement || container;
|
|
2271
|
+
if (optimizedHeight) {
|
|
2272
|
+
adElement.style.width = '100%';
|
|
2273
|
+
adElement.style.height = String(optimizedHeight);
|
|
1864
2274
|
}
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
2275
|
+
else {
|
|
2276
|
+
const { width, height } = this.calculateAdSize(containerElement, slot.adType, slot.config || {}, { debug: this.debug }) ||
|
|
2277
|
+
{ width: '100%', height: '250px' };
|
|
2278
|
+
adElement.style.width = width;
|
|
2279
|
+
adElement.style.height = height;
|
|
1869
2280
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
2281
|
+
switch (slot.adType) {
|
|
2282
|
+
case AdType.BANNER:
|
|
2283
|
+
if (ad.imageUrl) {
|
|
2284
|
+
await this.renderOptimizedBannerImage(adElement, ad, slot);
|
|
2285
|
+
}
|
|
2286
|
+
break;
|
|
2287
|
+
case AdType.TEXT: {
|
|
2288
|
+
const textDiv = document.createElement('div');
|
|
2289
|
+
textDiv.innerHTML = `
|
|
2290
|
+
<h3>${ad.title}</h3>
|
|
2291
|
+
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
2292
|
+
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
2293
|
+
`;
|
|
2294
|
+
adElement.appendChild(textDiv);
|
|
2295
|
+
break;
|
|
1880
2296
|
}
|
|
2297
|
+
case AdType.VIDEO:
|
|
2298
|
+
if (ad.videoUrl) {
|
|
2299
|
+
const video = document.createElement('video');
|
|
2300
|
+
video.src = ad.videoUrl;
|
|
2301
|
+
video.controls = true;
|
|
2302
|
+
video.style.width = '100%';
|
|
2303
|
+
video.style.height = '100%';
|
|
2304
|
+
adElement.appendChild(video);
|
|
2305
|
+
}
|
|
2306
|
+
break;
|
|
2307
|
+
default:
|
|
2308
|
+
adElement.innerHTML = `<div>${ad.title}</div>`;
|
|
1881
2309
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
if (sessionTime < 1000) {
|
|
1888
|
-
score += 25;
|
|
1889
|
-
reasons.push('Suspiciously fast interaction');
|
|
2310
|
+
if (ad.linkUrl) {
|
|
2311
|
+
adElement.style.cursor = 'pointer';
|
|
2312
|
+
adElement.addEventListener('click', () => {
|
|
2313
|
+
window.open(ad.linkUrl, '_blank');
|
|
2314
|
+
});
|
|
1890
2315
|
}
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
score: finalScore,
|
|
1894
|
-
riskLevel: this.getRiskLevel(finalScore),
|
|
1895
|
-
reasons: reasons
|
|
1896
|
-
};
|
|
1897
|
-
}
|
|
1898
|
-
detectWebDriver() {
|
|
1899
|
-
// 기본적인 웹드라이버 탐지
|
|
1900
|
-
return !!(window.webdriver ||
|
|
1901
|
-
navigator.webdriver ||
|
|
1902
|
-
window.__webdriver_evaluate ||
|
|
1903
|
-
window.__selenium_evaluate ||
|
|
1904
|
-
window.__webdriver_script_function ||
|
|
1905
|
-
window.__webdriver_script_func ||
|
|
1906
|
-
window.__webdriver_script_fn ||
|
|
1907
|
-
window.__fxdriver_evaluate ||
|
|
1908
|
-
window.__driver_unwrapped ||
|
|
1909
|
-
window.__webdriver_unwrapped ||
|
|
1910
|
-
window.__driver_evaluate ||
|
|
1911
|
-
window.__selenium_unwrapped ||
|
|
1912
|
-
window.__fxdriver_unwrapped);
|
|
2316
|
+
container.innerHTML = '';
|
|
2317
|
+
container.appendChild(adElement);
|
|
1913
2318
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2319
|
+
/**
|
|
2320
|
+
* Fallback 광고 렌더링 - 컨테이너 접기/생성
|
|
2321
|
+
*/
|
|
2322
|
+
renderFallback(slot) {
|
|
2323
|
+
const element = document.getElementById(slot.id);
|
|
2324
|
+
if (element) {
|
|
2325
|
+
const adstageContainers = [
|
|
2326
|
+
element.querySelector('[data-adstage-container="true"]'),
|
|
2327
|
+
element.closest('[data-adstage-container="true"]'),
|
|
2328
|
+
element
|
|
2329
|
+
].filter(el => el && el.hasAttribute('data-adstage-container'));
|
|
2330
|
+
const classBasedContainers = [
|
|
2331
|
+
element.closest('.adstage-slot'),
|
|
2332
|
+
element.closest('.adstage-banner'),
|
|
2333
|
+
element.closest('.adstage-text'),
|
|
2334
|
+
element.closest('.adstage-video'),
|
|
2335
|
+
element.closest('.adstage-native'),
|
|
2336
|
+
element.closest('.adstage-interstitial')
|
|
2337
|
+
].filter(Boolean);
|
|
2338
|
+
const generalContainers = [
|
|
2339
|
+
element.closest('[class*="ad"]'),
|
|
2340
|
+
element.closest('[class*="banner"]'),
|
|
2341
|
+
element.closest('[class*="container"]'),
|
|
2342
|
+
element.closest('div[style*="height"]'),
|
|
2343
|
+
element.closest('div[style*="min-height"]'),
|
|
2344
|
+
element.parentElement
|
|
2345
|
+
].filter(Boolean);
|
|
2346
|
+
const possibleContainers = [...adstageContainers, ...classBasedContainers, ...generalContainers];
|
|
2347
|
+
const targetContainer = possibleContainers[0];
|
|
2348
|
+
if (targetContainer) {
|
|
2349
|
+
let containerType = 'unknown';
|
|
2350
|
+
if (targetContainer.hasAttribute('data-adstage-container')) {
|
|
2351
|
+
containerType = 'adstage-official';
|
|
2352
|
+
}
|
|
2353
|
+
else if (targetContainer.classList.contains('adstage-slot')) {
|
|
2354
|
+
containerType = 'adstage-class';
|
|
2355
|
+
}
|
|
2356
|
+
else {
|
|
2357
|
+
containerType = 'generic';
|
|
2358
|
+
}
|
|
2359
|
+
targetContainer.style.cssText += `
|
|
2360
|
+
height: 0px !important;
|
|
2361
|
+
min-height: 0px !important;
|
|
2362
|
+
padding: 0px !important;
|
|
2363
|
+
margin: 0px !important;
|
|
2364
|
+
border: none !important;
|
|
2365
|
+
overflow: hidden !important;
|
|
2366
|
+
display: block !important;
|
|
2367
|
+
`;
|
|
2368
|
+
targetContainer.innerHTML = '';
|
|
2369
|
+
targetContainer.setAttribute('data-adstage-empty', 'true');
|
|
2370
|
+
if (this.debug) {
|
|
2371
|
+
console.warn(`⚠️ Ad container collapsed (${containerType}): ${slot.id}`, targetContainer);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
else {
|
|
2375
|
+
this.createEmptyContainer(slot);
|
|
2376
|
+
}
|
|
1919
2377
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2378
|
+
slot.advertisement = undefined;
|
|
2379
|
+
slot.isEmpty = true;
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* 빈 컨테이너 생성 (컨테이너를 찾지 못한 경우)
|
|
2383
|
+
*/
|
|
2384
|
+
createEmptyContainer(slot) {
|
|
2385
|
+
const originalContainer = document.getElementById(slot.containerId);
|
|
2386
|
+
if (originalContainer) {
|
|
2387
|
+
originalContainer.innerHTML = '';
|
|
2388
|
+
const emptyElement = document.createElement('div');
|
|
2389
|
+
emptyElement.id = slot.id;
|
|
2390
|
+
emptyElement.className = 'adstage-slot adstage-empty';
|
|
2391
|
+
emptyElement.setAttribute('data-adstage-container', 'true');
|
|
2392
|
+
emptyElement.setAttribute('data-adstage-empty', 'true');
|
|
2393
|
+
emptyElement.setAttribute('data-adstage-slot', slot.id);
|
|
2394
|
+
emptyElement.style.cssText = `
|
|
2395
|
+
height: 0px !important;
|
|
2396
|
+
min-height: 0px !important;
|
|
2397
|
+
padding: 0px !important;
|
|
2398
|
+
margin: 0px !important;
|
|
2399
|
+
border: none !important;
|
|
2400
|
+
overflow: hidden !important;
|
|
2401
|
+
display: block !important;
|
|
2402
|
+
`;
|
|
2403
|
+
originalContainer.appendChild(emptyElement);
|
|
2404
|
+
if (this.debug) {
|
|
2405
|
+
console.warn(`⚠️ Created empty AdStage container: ${slot.id}`);
|
|
2406
|
+
}
|
|
1923
2407
|
}
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2408
|
+
}
|
|
2409
|
+
/**
|
|
2410
|
+
* 광고 타입별 기본 높이 반환
|
|
2411
|
+
*/
|
|
2412
|
+
getDefaultHeightForAdType(type) {
|
|
2413
|
+
switch (type) {
|
|
2414
|
+
case AdType.BANNER:
|
|
2415
|
+
return '250px'; // 일반 배너
|
|
2416
|
+
case AdType.TEXT:
|
|
2417
|
+
return '120px'; // 텍스트는 좀 더 작게
|
|
2418
|
+
case AdType.VIDEO:
|
|
2419
|
+
return '360px'; // 비디오는 16:9 비율 고려
|
|
2420
|
+
case AdType.NATIVE:
|
|
2421
|
+
return '200px'; // 네이티브는 중간 크기
|
|
2422
|
+
case AdType.INTERSTITIAL:
|
|
2423
|
+
return '400px'; // 전면광고는 크게
|
|
2424
|
+
default:
|
|
2425
|
+
return '250px';
|
|
1927
2426
|
}
|
|
1928
|
-
return signatures.length > 0;
|
|
1929
2427
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
2428
|
+
/**
|
|
2429
|
+
* 컨테이너와 광고 타입에 따른 스마트한 크기 계산
|
|
2430
|
+
*/
|
|
2431
|
+
calculateAdSize(container, type, options, config) {
|
|
2432
|
+
// 사용자가 명시적으로 크기를 지정한 경우
|
|
2433
|
+
const explicitWidth = options.width;
|
|
2434
|
+
const explicitHeight = options.height;
|
|
2435
|
+
// 너비 처리
|
|
2436
|
+
let width;
|
|
2437
|
+
if (typeof explicitWidth === 'number') {
|
|
2438
|
+
width = `${explicitWidth}px`;
|
|
1937
2439
|
}
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
score += 15;
|
|
1941
|
-
reasons.push('Cookies disabled');
|
|
2440
|
+
else if (typeof explicitWidth === 'string') {
|
|
2441
|
+
width = explicitWidth;
|
|
1942
2442
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
score += 20;
|
|
1946
|
-
reasons.push('Invalid screen resolution');
|
|
2443
|
+
else {
|
|
2444
|
+
width = '100%'; // 기본값은 100%
|
|
1947
2445
|
}
|
|
1948
|
-
//
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
score += 5;
|
|
1953
|
-
reasons.push('No timezone info');
|
|
1954
|
-
}
|
|
2446
|
+
// 높이 처리 - 핵심 로직
|
|
2447
|
+
let height;
|
|
2448
|
+
if (typeof explicitHeight === 'number') {
|
|
2449
|
+
height = `${explicitHeight}px`;
|
|
1955
2450
|
}
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2451
|
+
else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
|
|
2452
|
+
// 명시적인 크기 문자열 (예: '200px', '50vh' 등)
|
|
2453
|
+
height = explicitHeight;
|
|
1959
2454
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
screenResolution: `${screen.width}x${screen.height}`,
|
|
1980
|
-
viewportSize: `${window.innerWidth}x${window.innerHeight}`
|
|
1981
|
-
};
|
|
1982
|
-
}
|
|
1983
|
-
destroy() {
|
|
1984
|
-
// 이벤트 리스너 정리는 생략 (메모리 누수 방지를 위해 필요시 구현)
|
|
2455
|
+
else {
|
|
2456
|
+
// 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
|
|
2457
|
+
const containerHeight = this.getContainerHeight(container);
|
|
2458
|
+
if (containerHeight > 0) {
|
|
2459
|
+
// 컨테이너에 높이가 있으면 100% 사용
|
|
2460
|
+
height = '100%';
|
|
2461
|
+
if (config?.debug) {
|
|
2462
|
+
console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
else {
|
|
2466
|
+
// 컨테이너에 높이가 없으면 타입별 기본값 사용 (나중에 동적 조정됨)
|
|
2467
|
+
height = this.getDefaultHeightForAdType(type);
|
|
2468
|
+
if (config?.debug) {
|
|
2469
|
+
console.log(`📏 Using default height ${height} (will be optimized for ${type})`);
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return { width, height };
|
|
1985
2474
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2475
|
+
/**
|
|
2476
|
+
* 컨테이너의 실제 높이 계산
|
|
2477
|
+
*/
|
|
2478
|
+
getContainerHeight(container) {
|
|
2479
|
+
// 현재 계산된 스타일에서 높이 확인
|
|
2480
|
+
const computedStyle = window.getComputedStyle(container);
|
|
2481
|
+
const height = parseFloat(computedStyle.height);
|
|
2482
|
+
// height가 auto이거나 0이면 다른 방법들 시도
|
|
2483
|
+
if (!height || height === 0) {
|
|
2484
|
+
// min-height 확인
|
|
2485
|
+
const minHeight = parseFloat(computedStyle.minHeight);
|
|
2486
|
+
if (minHeight > 0)
|
|
2487
|
+
return minHeight;
|
|
2488
|
+
// CSS로 설정된 고정 높이 확인
|
|
2489
|
+
if (container.style.height && container.style.height !== 'auto') {
|
|
2490
|
+
const styleHeight = parseFloat(container.style.height);
|
|
2491
|
+
if (styleHeight > 0)
|
|
2492
|
+
return styleHeight;
|
|
2493
|
+
}
|
|
2494
|
+
// 속성으로 설정된 높이 확인
|
|
2495
|
+
const heightAttr = container.getAttribute('height');
|
|
2496
|
+
if (heightAttr) {
|
|
2497
|
+
const attrHeight = parseFloat(heightAttr);
|
|
2498
|
+
if (attrHeight > 0)
|
|
2499
|
+
return attrHeight;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
return height || 0;
|
|
2014
2503
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
* 이벤트 엔드포인트
|
|
2030
|
-
*/
|
|
2031
|
-
this.events = {
|
|
2032
|
-
track: () => `${this.baseUrl}${API_PATHS.events.track}`,
|
|
2033
|
-
batch: () => `${this.baseUrl}${API_PATHS.events.batch}`
|
|
2034
|
-
};
|
|
2035
|
-
// 기본값은 베타 환경 사용
|
|
2036
|
-
this.baseUrl = baseUrl || API_ENDPOINTS.beta;
|
|
2504
|
+
/**
|
|
2505
|
+
* 이미지 로드 및 실제 크기 획득 (Promise 기반)
|
|
2506
|
+
*/
|
|
2507
|
+
loadImageDimensions(imageUrl) {
|
|
2508
|
+
return new Promise((resolve, reject) => {
|
|
2509
|
+
const img = new Image();
|
|
2510
|
+
img.onload = () => {
|
|
2511
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
2512
|
+
};
|
|
2513
|
+
img.onerror = () => {
|
|
2514
|
+
reject(new Error(`Failed to load image: ${imageUrl}`));
|
|
2515
|
+
};
|
|
2516
|
+
img.src = imageUrl;
|
|
2517
|
+
});
|
|
2037
2518
|
}
|
|
2038
2519
|
/**
|
|
2039
|
-
*
|
|
2520
|
+
* 이미지와 컨테이너 비율을 고려한 최적화 스타일 적용
|
|
2040
2521
|
*/
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2522
|
+
applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio) {
|
|
2523
|
+
const ratio = imageAspectRatio / containerAspectRatio;
|
|
2524
|
+
if (Math.abs(ratio - 1) < 0.1) {
|
|
2525
|
+
// 비율이 거의 같으면 cover 사용
|
|
2526
|
+
img.style.objectFit = 'cover';
|
|
2527
|
+
img.style.objectPosition = 'center';
|
|
2528
|
+
}
|
|
2529
|
+
else if (ratio > 1.3) {
|
|
2530
|
+
// 이미지가 훨씬 가로형이면 contain으로 전체 보이기
|
|
2531
|
+
img.style.objectFit = 'contain';
|
|
2532
|
+
img.style.objectPosition = 'center';
|
|
2533
|
+
img.style.backgroundColor = '#f0f0f0'; // 빈 공간 배경색
|
|
2534
|
+
}
|
|
2535
|
+
else if (ratio < 0.7) {
|
|
2536
|
+
// 이미지가 훨씬 세로형이면 cover로 채우기
|
|
2537
|
+
img.style.objectFit = 'cover';
|
|
2538
|
+
img.style.objectPosition = 'center';
|
|
2539
|
+
}
|
|
2540
|
+
else {
|
|
2541
|
+
// 적당한 차이면 스마트 cover
|
|
2542
|
+
img.style.objectFit = 'cover';
|
|
2543
|
+
img.style.objectPosition = 'center';
|
|
2544
|
+
}
|
|
2545
|
+
if (this.debug) {
|
|
2546
|
+
console.log(`🎨 Image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
|
|
2547
|
+
}
|
|
2044
2548
|
}
|
|
2045
2549
|
/**
|
|
2046
|
-
*
|
|
2550
|
+
* 배너 이미지 최적화 렌더링
|
|
2551
|
+
* 이미지 실제 크기를 로드한 후 컨테이너와 비율을 맞춤
|
|
2047
2552
|
*/
|
|
2048
|
-
|
|
2049
|
-
|
|
2553
|
+
async renderOptimizedBannerImage(container, advertisement, slot) {
|
|
2554
|
+
const img = document.createElement('img');
|
|
2555
|
+
// 기본 스타일 설정
|
|
2556
|
+
img.style.width = '100%';
|
|
2557
|
+
img.style.height = '100%';
|
|
2558
|
+
img.style.display = 'block';
|
|
2559
|
+
img.style.borderRadius = '8px';
|
|
2560
|
+
img.alt = advertisement.title || 'Advertisement';
|
|
2561
|
+
// 이미지 로딩 상태 표시
|
|
2562
|
+
container.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #999;">Loading...</div>';
|
|
2563
|
+
try {
|
|
2564
|
+
// 이미지 URL 체크
|
|
2565
|
+
if (!advertisement.imageUrl) {
|
|
2566
|
+
throw new Error('Image URL is not provided');
|
|
2567
|
+
}
|
|
2568
|
+
// 🆕 이미지 실제 크기 로드
|
|
2569
|
+
const imageDimensions = await this.loadImageDimensions(advertisement.imageUrl);
|
|
2570
|
+
// 컨테이너 크기 정보
|
|
2571
|
+
const containerRect = container.getBoundingClientRect();
|
|
2572
|
+
const containerWidth = containerRect.width;
|
|
2573
|
+
const containerHeight = containerRect.height;
|
|
2574
|
+
if (this.debug) {
|
|
2575
|
+
console.log(`📸 Image dimensions: ${imageDimensions.width}x${imageDimensions.height}`);
|
|
2576
|
+
console.log(`📦 Container dimensions: ${containerWidth}x${containerHeight}`);
|
|
2577
|
+
}
|
|
2578
|
+
// 비율 계산
|
|
2579
|
+
const imageAspectRatio = imageDimensions.width / imageDimensions.height;
|
|
2580
|
+
const containerAspectRatio = containerWidth / containerHeight;
|
|
2581
|
+
// 🆕 스마트 스타일 적용
|
|
2582
|
+
this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
|
|
2583
|
+
// 이미지 설정 및 추가
|
|
2584
|
+
img.src = advertisement.imageUrl;
|
|
2585
|
+
// 컨테이너 클리어 후 이미지 추가
|
|
2586
|
+
container.innerHTML = '';
|
|
2587
|
+
container.appendChild(img);
|
|
2588
|
+
// 클릭 이벤트 등록
|
|
2589
|
+
if (advertisement.linkUrl) {
|
|
2590
|
+
img.style.cursor = 'pointer';
|
|
2591
|
+
img.addEventListener('click', () => {
|
|
2592
|
+
if (this.advertisementEventTracker) {
|
|
2593
|
+
// AdvertisementEventTracker의 실제 메소드명을 확인해야 함
|
|
2594
|
+
console.log(`Click tracked for ad: ${advertisement._id}`);
|
|
2595
|
+
}
|
|
2596
|
+
window.open(advertisement.linkUrl, '_blank');
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
if (this.debug) {
|
|
2600
|
+
console.log(`✅ Optimized banner image rendered for ad: ${advertisement._id}`);
|
|
2601
|
+
}
|
|
2602
|
+
return img;
|
|
2603
|
+
}
|
|
2604
|
+
catch (error) {
|
|
2605
|
+
console.error('❌ Failed to load optimized banner image:', error);
|
|
2606
|
+
// 폴백: 일반 이미지 렌더링
|
|
2607
|
+
if (advertisement.imageUrl) {
|
|
2608
|
+
img.src = advertisement.imageUrl;
|
|
2609
|
+
img.style.objectFit = 'cover';
|
|
2610
|
+
img.style.objectPosition = 'center';
|
|
2611
|
+
container.innerHTML = '';
|
|
2612
|
+
container.appendChild(img);
|
|
2613
|
+
if (advertisement.linkUrl) {
|
|
2614
|
+
img.style.cursor = 'pointer';
|
|
2615
|
+
img.addEventListener('click', () => {
|
|
2616
|
+
if (this.advertisementEventTracker) {
|
|
2617
|
+
console.log(`Click tracked for ad: ${advertisement._id}`);
|
|
2618
|
+
}
|
|
2619
|
+
window.open(advertisement.linkUrl, '_blank');
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
return img;
|
|
2624
|
+
}
|
|
2050
2625
|
}
|
|
2051
2626
|
/**
|
|
2052
|
-
*
|
|
2627
|
+
* 디버그 로그 출력
|
|
2053
2628
|
*/
|
|
2054
|
-
|
|
2055
|
-
|
|
2629
|
+
log(message, ...args) {
|
|
2630
|
+
if (this.debug) {
|
|
2631
|
+
console.log(message, ...args);
|
|
2632
|
+
}
|
|
2056
2633
|
}
|
|
2057
|
-
}
|
|
2058
|
-
/**
|
|
2059
|
-
* 전역 엔드포인트 빌더 인스턴스 (기본: 베타 환경)
|
|
2060
|
-
*/
|
|
2061
|
-
const endpoints = new EndpointBuilder();
|
|
2634
|
+
}
|
|
2062
2635
|
|
|
2063
2636
|
/**
|
|
2064
2637
|
* AdStage SDK - Ads 모듈
|
|
@@ -2071,6 +2644,8 @@ class AdsModule {
|
|
|
2071
2644
|
this.slots = new Map();
|
|
2072
2645
|
// Advertisement 이벤트 추적 관련
|
|
2073
2646
|
this.advertisementEventTracker = null;
|
|
2647
|
+
// 렌더링 관련
|
|
2648
|
+
this.adRenderer = null;
|
|
2074
2649
|
}
|
|
2075
2650
|
/**
|
|
2076
2651
|
* Ads 모듈 초기화 (동기)
|
|
@@ -2079,6 +2654,8 @@ class AdsModule {
|
|
|
2079
2654
|
this._config = config;
|
|
2080
2655
|
// AdvertisementEventTracker 초기화 (환경 자동 감지된 엔드포인트 사용)
|
|
2081
2656
|
this.advertisementEventTracker = new AdvertisementEventTracker(endpoints.getBaseUrl(), config.apiKey, config.debug || false, this.slots);
|
|
2657
|
+
// AdRenderer 초기화
|
|
2658
|
+
this.adRenderer = new AdRenderer(config.debug || false, this.advertisementEventTracker);
|
|
2082
2659
|
this._isReady = true;
|
|
2083
2660
|
if (config.debug) {
|
|
2084
2661
|
console.log('🎯 Ads module initialized (sync mode)');
|
|
@@ -2228,8 +2805,8 @@ class AdsModule {
|
|
|
2228
2805
|
}
|
|
2229
2806
|
// 고유한 슬롯 ID 생성
|
|
2230
2807
|
const slotId = `adstage-${type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
2231
|
-
// 즉시 placeholder 생성
|
|
2232
|
-
this.
|
|
2808
|
+
// 즉시 placeholder 생성 (AdRenderer 위임)
|
|
2809
|
+
this.adRenderer?.createPlaceholder(container, slotId, type, options, this._config);
|
|
2233
2810
|
// 광고 슬롯 정보 저장
|
|
2234
2811
|
const slot = {
|
|
2235
2812
|
id: slotId,
|
|
@@ -2245,7 +2822,7 @@ class AdsModule {
|
|
|
2245
2822
|
advertisement: undefined, // 나중에 로드
|
|
2246
2823
|
config: { type, ...options },
|
|
2247
2824
|
load: async () => this.fetchAdData(type, options).then(ads => ads[0] || null),
|
|
2248
|
-
render: (ad) => this.renderAdElement(slot, ad),
|
|
2825
|
+
render: (ad) => this.adRenderer?.renderAdElement(slot, ad),
|
|
2249
2826
|
refresh: async () => this.refreshAdSlot(slot),
|
|
2250
2827
|
destroy: () => this.destroy(slotId)
|
|
2251
2828
|
};
|
|
@@ -2259,293 +2836,19 @@ class AdsModule {
|
|
|
2259
2836
|
}
|
|
2260
2837
|
return slotId;
|
|
2261
2838
|
}
|
|
2262
|
-
|
|
2263
|
-
* 즉시 광고 슬롯 생성 (placeholder)
|
|
2264
|
-
*/
|
|
2265
|
-
createAdSlot(container, slotId, type, options) {
|
|
2266
|
-
const adElement = document.createElement('div');
|
|
2267
|
-
adElement.id = slotId;
|
|
2268
|
-
adElement.className = `adstage-slot adstage-${type.toLowerCase()}`;
|
|
2269
|
-
// 확실한 컨테이너 식별을 위한 데이터 속성 추가
|
|
2270
|
-
adElement.setAttribute('data-adstage-container', 'true');
|
|
2271
|
-
adElement.setAttribute('data-adstage-type', type);
|
|
2272
|
-
adElement.setAttribute('data-adstage-slot', slotId);
|
|
2273
|
-
// 스마트한 크기 설정
|
|
2274
|
-
const { width, height } = this.calculateAdSize(container, type, options);
|
|
2275
|
-
adElement.style.width = width;
|
|
2276
|
-
adElement.style.height = height;
|
|
2277
|
-
adElement.style.border = '1px dashed #ccc';
|
|
2278
|
-
adElement.style.display = 'flex';
|
|
2279
|
-
adElement.style.alignItems = 'center';
|
|
2280
|
-
adElement.style.justifyContent = 'center';
|
|
2281
|
-
adElement.style.backgroundColor = '#f9f9f9';
|
|
2282
|
-
adElement.style.color = '#666';
|
|
2283
|
-
adElement.innerHTML = `<span>Loading ${type} ad...</span>`;
|
|
2284
|
-
container.appendChild(adElement);
|
|
2285
|
-
if (this._config?.debug) {
|
|
2286
|
-
console.log(`📦 Placeholder created for slot: ${slotId} (${width} x ${height})`);
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
/**
|
|
2290
|
-
* 컨테이너와 광고 타입에 따른 스마트한 크기 계산
|
|
2291
|
-
*/
|
|
2292
|
-
calculateAdSize(container, type, options) {
|
|
2293
|
-
// 사용자가 명시적으로 크기를 지정한 경우
|
|
2294
|
-
const explicitWidth = options.width;
|
|
2295
|
-
const explicitHeight = options.height;
|
|
2296
|
-
// 너비 처리
|
|
2297
|
-
let width;
|
|
2298
|
-
if (typeof explicitWidth === 'number') {
|
|
2299
|
-
width = `${explicitWidth}px`;
|
|
2300
|
-
}
|
|
2301
|
-
else if (typeof explicitWidth === 'string') {
|
|
2302
|
-
width = explicitWidth;
|
|
2303
|
-
}
|
|
2304
|
-
else {
|
|
2305
|
-
width = '100%'; // 기본값은 100%
|
|
2306
|
-
}
|
|
2307
|
-
// 높이 처리 - 핵심 로직
|
|
2308
|
-
let height;
|
|
2309
|
-
if (typeof explicitHeight === 'number') {
|
|
2310
|
-
height = `${explicitHeight}px`;
|
|
2311
|
-
}
|
|
2312
|
-
else if (typeof explicitHeight === 'string' && explicitHeight !== '100%' && explicitHeight !== 'auto') {
|
|
2313
|
-
// 명시적인 크기 문자열 (예: '200px', '50vh' 등)
|
|
2314
|
-
height = explicitHeight;
|
|
2315
|
-
}
|
|
2316
|
-
else {
|
|
2317
|
-
// 100%, auto이거나 높이가 지정되지 않은 경우 스마트 계산
|
|
2318
|
-
const containerHeight = this.getContainerHeight(container);
|
|
2319
|
-
if (containerHeight > 0) {
|
|
2320
|
-
// 컨테이너에 높이가 있으면 100% 사용
|
|
2321
|
-
height = '100%';
|
|
2322
|
-
if (this._config?.debug) {
|
|
2323
|
-
console.log(`📏 Using 100% height (container: ${containerHeight}px)`);
|
|
2324
|
-
}
|
|
2325
|
-
}
|
|
2326
|
-
else {
|
|
2327
|
-
// 컨테이너에 높이가 없으면 타입별 기본값 사용 (나중에 동적 조정됨)
|
|
2328
|
-
height = this.getDefaultHeightForAdType(type);
|
|
2329
|
-
if (this._config?.debug) {
|
|
2330
|
-
console.log(`📏 Using default height ${height} (will be optimized for ${type})`);
|
|
2331
|
-
}
|
|
2332
|
-
}
|
|
2333
|
-
}
|
|
2334
|
-
return { width, height };
|
|
2335
|
-
}
|
|
2336
|
-
/**
|
|
2337
|
-
* 컨테이너의 실제 높이 계산
|
|
2338
|
-
*/
|
|
2339
|
-
getContainerHeight(container) {
|
|
2340
|
-
// 현재 계산된 스타일에서 높이 확인
|
|
2341
|
-
const computedStyle = window.getComputedStyle(container);
|
|
2342
|
-
const height = parseFloat(computedStyle.height);
|
|
2343
|
-
// height가 auto이거나 0이면 다른 방법들 시도
|
|
2344
|
-
if (!height || height === 0) {
|
|
2345
|
-
// min-height 확인
|
|
2346
|
-
const minHeight = parseFloat(computedStyle.minHeight);
|
|
2347
|
-
if (minHeight > 0)
|
|
2348
|
-
return minHeight;
|
|
2349
|
-
// CSS로 설정된 고정 높이 확인
|
|
2350
|
-
if (container.style.height && container.style.height !== 'auto') {
|
|
2351
|
-
const styleHeight = parseFloat(container.style.height);
|
|
2352
|
-
if (styleHeight > 0)
|
|
2353
|
-
return styleHeight;
|
|
2354
|
-
}
|
|
2355
|
-
// 속성으로 설정된 높이 확인
|
|
2356
|
-
const heightAttr = container.getAttribute('height');
|
|
2357
|
-
if (heightAttr) {
|
|
2358
|
-
const attrHeight = parseFloat(heightAttr);
|
|
2359
|
-
if (attrHeight > 0)
|
|
2360
|
-
return attrHeight;
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
return height || 0;
|
|
2364
|
-
}
|
|
2365
|
-
/**
|
|
2366
|
-
* 광고 타입별 기본 높이 반환
|
|
2367
|
-
*/
|
|
2368
|
-
getDefaultHeightForAdType(type) {
|
|
2369
|
-
switch (type) {
|
|
2370
|
-
case AdType.BANNER:
|
|
2371
|
-
return '250px'; // 일반 배너
|
|
2372
|
-
case AdType.TEXT:
|
|
2373
|
-
return '120px'; // 텍스트는 좀 더 작게
|
|
2374
|
-
case AdType.VIDEO:
|
|
2375
|
-
return '360px'; // 비디오는 16:9 비율 고려
|
|
2376
|
-
case AdType.NATIVE:
|
|
2377
|
-
return '200px'; // 네이티브는 중간 크기
|
|
2378
|
-
case AdType.INTERSTITIAL:
|
|
2379
|
-
return '400px'; // 전면광고는 크게
|
|
2380
|
-
default:
|
|
2381
|
-
return '250px';
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
/**
|
|
2385
|
-
* 이미지 크기 정보 로드 (프리로딩)
|
|
2386
|
-
*/
|
|
2387
|
-
async loadImageDimensions(imageUrl) {
|
|
2388
|
-
return new Promise((resolve, reject) => {
|
|
2389
|
-
const img = new Image();
|
|
2390
|
-
img.onload = () => {
|
|
2391
|
-
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
2392
|
-
};
|
|
2393
|
-
img.onerror = () => {
|
|
2394
|
-
reject(new Error(`Failed to load image: ${imageUrl}`));
|
|
2395
|
-
};
|
|
2396
|
-
img.src = imageUrl;
|
|
2397
|
-
});
|
|
2398
|
-
}
|
|
2839
|
+
// createAdSlot 제거: AdRenderer.createPlaceholder 사용
|
|
2399
2840
|
/**
|
|
2400
2841
|
* 여러 광고의 최적 컨테이너 크기 계산 (동적 크기 조정)
|
|
2401
2842
|
*/
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
try {
|
|
2412
|
-
// 모든 배너 이미지의 크기 정보 로드
|
|
2413
|
-
const imageDimensions = await Promise.allSettled(advertisements
|
|
2414
|
-
.filter(ad => ad.imageUrl)
|
|
2415
|
-
.map(ad => this.loadImageDimensions(ad.imageUrl)));
|
|
2416
|
-
const validDimensions = imageDimensions
|
|
2417
|
-
.filter((result) => result.status === 'fulfilled')
|
|
2418
|
-
.map(result => result.value);
|
|
2419
|
-
if (validDimensions.length === 0) {
|
|
2420
|
-
// 이미지 로드 실패시 기본값
|
|
2421
|
-
return {
|
|
2422
|
-
width: '100%',
|
|
2423
|
-
height: this.getDefaultHeightForAdType(adType),
|
|
2424
|
-
aspectRatio: 16 / 9
|
|
2425
|
-
};
|
|
2426
|
-
}
|
|
2427
|
-
// 최적 전략 선택
|
|
2428
|
-
const strategy = this.selectOptimalSizeStrategy(validDimensions);
|
|
2429
|
-
const optimalHeight = this.calculateOptimalHeight(validDimensions, containerWidth, strategy);
|
|
2430
|
-
if (this._config?.debug) {
|
|
2431
|
-
console.log(`📐 Optimal container calculated: ${containerWidth}x${optimalHeight} (strategy: ${strategy})`);
|
|
2432
|
-
}
|
|
2433
|
-
return {
|
|
2434
|
-
width: '100%',
|
|
2435
|
-
height: `${optimalHeight}px`,
|
|
2436
|
-
aspectRatio: containerWidth / optimalHeight
|
|
2437
|
-
};
|
|
2438
|
-
}
|
|
2439
|
-
catch (error) {
|
|
2440
|
-
console.warn('Failed to calculate optimal size, using defaults:', error);
|
|
2441
|
-
return {
|
|
2442
|
-
width: '100%',
|
|
2443
|
-
height: this.getDefaultHeightForAdType(adType),
|
|
2444
|
-
aspectRatio: 16 / 9
|
|
2445
|
-
};
|
|
2446
|
-
}
|
|
2447
|
-
}
|
|
2448
|
-
/**
|
|
2449
|
-
* 최적 크기 조정 전략 선택
|
|
2450
|
-
*/
|
|
2451
|
-
selectOptimalSizeStrategy(dimensions) {
|
|
2452
|
-
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
2453
|
-
// 1. 공통 비율이 있는지 확인 (±0.1 허용)
|
|
2454
|
-
const ratioGroups = new Map();
|
|
2455
|
-
aspectRatios.forEach(ratio => {
|
|
2456
|
-
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
2457
|
-
const key = roundedRatio.toString();
|
|
2458
|
-
ratioGroups.set(key, (ratioGroups.get(key) || 0) + 1);
|
|
2459
|
-
});
|
|
2460
|
-
const maxGroup = Math.max(...ratioGroups.values());
|
|
2461
|
-
const totalImages = dimensions.length;
|
|
2462
|
-
// 70% 이상이 비슷한 비율이면 dominant 전략
|
|
2463
|
-
if (maxGroup / totalImages >= 0.7) {
|
|
2464
|
-
return 'dominant';
|
|
2465
|
-
}
|
|
2466
|
-
// 표준 비율들이 많으면 common 전략
|
|
2467
|
-
const standardRatios = [16 / 9, 4 / 3, 1 / 1, 3 / 2];
|
|
2468
|
-
const standardCount = aspectRatios.filter(ratio => standardRatios.some(standard => Math.abs(ratio - standard) < 0.1)).length;
|
|
2469
|
-
if (standardCount / totalImages >= 0.5) {
|
|
2470
|
-
return 'common';
|
|
2471
|
-
}
|
|
2472
|
-
// 기본은 평균 전략
|
|
2473
|
-
return 'average';
|
|
2474
|
-
}
|
|
2475
|
-
/**
|
|
2476
|
-
* 전략에 따른 최적 높이 계산
|
|
2477
|
-
*/
|
|
2478
|
-
calculateOptimalHeight(dimensions, containerWidth, strategy) {
|
|
2479
|
-
const aspectRatios = dimensions.map(d => d.width / d.height);
|
|
2480
|
-
switch (strategy) {
|
|
2481
|
-
case 'dominant':
|
|
2482
|
-
// 가장 많은 비율을 기준으로
|
|
2483
|
-
const ratioGroups = new Map();
|
|
2484
|
-
aspectRatios.forEach(ratio => {
|
|
2485
|
-
const roundedRatio = Math.round(ratio * 10) / 10;
|
|
2486
|
-
const key = roundedRatio.toString();
|
|
2487
|
-
const existing = ratioGroups.get(key);
|
|
2488
|
-
if (existing) {
|
|
2489
|
-
existing.count++;
|
|
2490
|
-
}
|
|
2491
|
-
else {
|
|
2492
|
-
ratioGroups.set(key, { ratio: roundedRatio, count: 1 });
|
|
2493
|
-
}
|
|
2494
|
-
});
|
|
2495
|
-
const dominantGroup = Array.from(ratioGroups.values())
|
|
2496
|
-
.reduce((max, current) => current.count > max.count ? current : max);
|
|
2497
|
-
return Math.round(containerWidth / dominantGroup.ratio);
|
|
2498
|
-
case 'common':
|
|
2499
|
-
// 표준 비율 중 가장 적합한 것 선택
|
|
2500
|
-
const standardRatios = [
|
|
2501
|
-
{ ratio: 16 / 9, name: '16:9' },
|
|
2502
|
-
{ ratio: 4 / 3, name: '4:3' },
|
|
2503
|
-
{ ratio: 1 / 1, name: '1:1' },
|
|
2504
|
-
{ ratio: 3 / 2, name: '3:2' }
|
|
2505
|
-
];
|
|
2506
|
-
const avgRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
2507
|
-
const bestStandard = standardRatios.reduce((best, current) => Math.abs(current.ratio - avgRatio) < Math.abs(best.ratio - avgRatio) ? current : best);
|
|
2508
|
-
if (this._config?.debug) {
|
|
2509
|
-
console.log(`📊 Using standard ratio: ${bestStandard.name} (avg: ${avgRatio.toFixed(2)})`);
|
|
2510
|
-
}
|
|
2511
|
-
return Math.round(containerWidth / bestStandard.ratio);
|
|
2512
|
-
case 'average':
|
|
2513
|
-
default:
|
|
2514
|
-
// 평균 비율 사용
|
|
2515
|
-
const averageRatio = aspectRatios.reduce((sum, ratio) => sum + ratio, 0) / aspectRatios.length;
|
|
2516
|
-
return Math.round(containerWidth / averageRatio);
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
/**
|
|
2520
|
-
* 개별 이미지에 최적화된 렌더링 스타일 적용
|
|
2521
|
-
*/
|
|
2522
|
-
applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio) {
|
|
2523
|
-
const ratio = imageAspectRatio / containerAspectRatio;
|
|
2524
|
-
if (Math.abs(ratio - 1) < 0.1) {
|
|
2525
|
-
// 비율이 거의 같으면 cover 사용
|
|
2526
|
-
img.style.objectFit = 'cover';
|
|
2527
|
-
img.style.objectPosition = 'center';
|
|
2528
|
-
}
|
|
2529
|
-
else if (ratio > 1.3) {
|
|
2530
|
-
// 이미지가 훨씬 가로형이면 contain으로 전체 보이기
|
|
2531
|
-
img.style.objectFit = 'contain';
|
|
2532
|
-
img.style.objectPosition = 'center';
|
|
2533
|
-
img.style.backgroundColor = '#f0f0f0'; // 빈 공간 배경색
|
|
2534
|
-
}
|
|
2535
|
-
else if (ratio < 0.7) {
|
|
2536
|
-
// 이미지가 훨씬 세로형이면 cover로 채우기
|
|
2537
|
-
img.style.objectFit = 'cover';
|
|
2538
|
-
img.style.objectPosition = 'center';
|
|
2539
|
-
}
|
|
2540
|
-
else {
|
|
2541
|
-
// 적당한 차이면 스마트 cover
|
|
2542
|
-
img.style.objectFit = 'cover';
|
|
2543
|
-
img.style.objectPosition = 'center';
|
|
2544
|
-
}
|
|
2545
|
-
if (this._config?.debug) {
|
|
2546
|
-
console.log(`🎨 Image style applied: objectFit=${img.style.objectFit}, ratio=${ratio.toFixed(2)}`);
|
|
2547
|
-
}
|
|
2548
|
-
}
|
|
2843
|
+
// calculateOptimalContainerSize 제거: AdRenderer.calculateOptimalContainerSize 사용
|
|
2844
|
+
/**
|
|
2845
|
+
* 최적 크기 조정 전략 선택
|
|
2846
|
+
*/
|
|
2847
|
+
// selectOptimalSizeStrategy 제거: AdRenderer 내부 구현 사용
|
|
2848
|
+
/**
|
|
2849
|
+
* 전략에 따른 최적 높이 계산
|
|
2850
|
+
*/
|
|
2851
|
+
// calculateOptimalHeight 제거: AdRenderer 내부 구현 사용
|
|
2549
2852
|
/**
|
|
2550
2853
|
* 백그라운드에서 광고 콘텐츠 로드
|
|
2551
2854
|
*/
|
|
@@ -2554,21 +2857,21 @@ class AdsModule {
|
|
|
2554
2857
|
// 광고 데이터 가져오기 - 여러 개 로드
|
|
2555
2858
|
const adstageData = await this.fetchAdData(slot.adType, slot.config);
|
|
2556
2859
|
if (!adstageData || adstageData.length === 0) {
|
|
2557
|
-
this.renderFallback(slot);
|
|
2860
|
+
this.adRenderer?.renderFallback(slot);
|
|
2558
2861
|
return;
|
|
2559
2862
|
}
|
|
2560
2863
|
// 🆕 동적 크기 조정: 배너 광고의 경우 이미지 크기 기반으로 컨테이너 최적화
|
|
2561
2864
|
if (slot.adType === AdType.BANNER && adstageData.length > 0) {
|
|
2562
|
-
await this.optimizeContainerForBannerAds(slot, adstageData);
|
|
2865
|
+
await this.adRenderer?.optimizeContainerForBannerAds(slot, adstageData);
|
|
2563
2866
|
}
|
|
2564
2867
|
// 광고가 여러 개이거나 autoSlide 옵션이 있으면 슬라이더로 렌더링
|
|
2565
2868
|
if (adstageData.length > 1 || slot.config?.autoSlide) {
|
|
2566
|
-
await this.renderAdSlider(slot, adstageData);
|
|
2869
|
+
await this.adRenderer?.renderAdSlider(slot, adstageData);
|
|
2567
2870
|
}
|
|
2568
2871
|
else {
|
|
2569
2872
|
// 광고가 1개면 일반 렌더링
|
|
2570
2873
|
slot.advertisement = adstageData[0];
|
|
2571
|
-
await this.renderAdElement(slot, adstageData[0]);
|
|
2874
|
+
await this.adRenderer?.renderAdElement(slot, adstageData[0]);
|
|
2572
2875
|
// ✅ 신규: Viewable impression 추적 시작 (기존 즉시 추적 대신)
|
|
2573
2876
|
this.startBasicViewabilityTracking(slot, adstageData[0]);
|
|
2574
2877
|
}
|
|
@@ -2579,35 +2882,13 @@ class AdsModule {
|
|
|
2579
2882
|
}
|
|
2580
2883
|
catch (error) {
|
|
2581
2884
|
console.error(`❌ Failed to load ad for slot: ${slot.id}`, error);
|
|
2582
|
-
this.renderFallback(slot);
|
|
2885
|
+
this.adRenderer?.renderFallback(slot);
|
|
2583
2886
|
}
|
|
2584
2887
|
}
|
|
2585
2888
|
/**
|
|
2586
2889
|
* 배너 광고를 위한 컨테이너 최적화
|
|
2587
2890
|
*/
|
|
2588
|
-
|
|
2589
|
-
try {
|
|
2590
|
-
const container = document.getElementById(slot.containerId);
|
|
2591
|
-
const adElement = document.getElementById(slot.id);
|
|
2592
|
-
if (!container || !adElement)
|
|
2593
|
-
return;
|
|
2594
|
-
// 현재 컨테이너 너비 확인
|
|
2595
|
-
const containerWidth = container.getBoundingClientRect().width || 300;
|
|
2596
|
-
// 최적 크기 계산
|
|
2597
|
-
const optimalSize = await this.calculateOptimalContainerSize(advertisements, containerWidth, slot.adType);
|
|
2598
|
-
// 컨테이너 크기 동적 조정
|
|
2599
|
-
adElement.style.height = optimalSize.height;
|
|
2600
|
-
// 슬롯 정보 업데이트
|
|
2601
|
-
slot.optimizedHeight = optimalSize.height;
|
|
2602
|
-
slot.aspectRatio = optimalSize.aspectRatio;
|
|
2603
|
-
if (this._config?.debug) {
|
|
2604
|
-
console.log(`🔧 Container optimized for ${advertisements.length} banner ads: ${optimalSize.height}`);
|
|
2605
|
-
}
|
|
2606
|
-
}
|
|
2607
|
-
catch (error) {
|
|
2608
|
-
console.warn('Container optimization failed, using default size:', error);
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2891
|
+
// optimizeContainerForBannerAds 제거: AdRenderer.optimizeContainerForBannerAds 사용
|
|
2611
2892
|
/**
|
|
2612
2893
|
* 기본 viewability 추적 시작
|
|
2613
2894
|
*/
|
|
@@ -2661,109 +2942,11 @@ class AdsModule {
|
|
|
2661
2942
|
/**
|
|
2662
2943
|
* Fallback 광고 렌더링 - AdStage 확실한 컨테이너 우선 탐지
|
|
2663
2944
|
*/
|
|
2664
|
-
renderFallback
|
|
2665
|
-
const element = document.getElementById(slot.id);
|
|
2666
|
-
if (element) {
|
|
2667
|
-
// 1순위: AdStage가 생성한 확실한 컨테이너들 (데이터 속성 기반)
|
|
2668
|
-
const adstageContainers = [
|
|
2669
|
-
element.querySelector('[data-adstage-container="true"]'), // 내부 AdStage 컨테이너
|
|
2670
|
-
element.closest('[data-adstage-container="true"]'), // 상위 AdStage 컨테이너
|
|
2671
|
-
element, // 자기 자신이 AdStage 컨테이너인 경우
|
|
2672
|
-
].filter(el => el && el.hasAttribute('data-adstage-container'));
|
|
2673
|
-
// 2순위: AdStage 클래스 기반 컨테이너들
|
|
2674
|
-
const classBasedContainers = [
|
|
2675
|
-
element.closest('.adstage-slot'),
|
|
2676
|
-
element.closest('.adstage-banner'),
|
|
2677
|
-
element.closest('.adstage-text'),
|
|
2678
|
-
element.closest('.adstage-video'),
|
|
2679
|
-
element.closest('.adstage-native'),
|
|
2680
|
-
element.closest('.adstage-interstitial'),
|
|
2681
|
-
].filter(Boolean);
|
|
2682
|
-
// 3순위: 일반적인 광고 컨테이너 패턴들 (fallback)
|
|
2683
|
-
const generalContainers = [
|
|
2684
|
-
element.closest('[class*="ad"]'),
|
|
2685
|
-
element.closest('[class*="banner"]'),
|
|
2686
|
-
element.closest('[class*="container"]'),
|
|
2687
|
-
element.closest('div[style*="height"]'),
|
|
2688
|
-
element.closest('div[style*="min-height"]'),
|
|
2689
|
-
element.parentElement
|
|
2690
|
-
].filter(Boolean);
|
|
2691
|
-
// 우선순위에 따라 컨테이너 선택
|
|
2692
|
-
const possibleContainers = [
|
|
2693
|
-
...adstageContainers,
|
|
2694
|
-
...classBasedContainers,
|
|
2695
|
-
...generalContainers
|
|
2696
|
-
];
|
|
2697
|
-
// 가장 적절한 컨테이너 선택
|
|
2698
|
-
const targetContainer = possibleContainers[0];
|
|
2699
|
-
if (targetContainer) {
|
|
2700
|
-
// 컨테이너 타입 로깅
|
|
2701
|
-
let containerType = 'unknown';
|
|
2702
|
-
if (targetContainer.hasAttribute('data-adstage-container')) {
|
|
2703
|
-
containerType = 'adstage-official';
|
|
2704
|
-
}
|
|
2705
|
-
else if (targetContainer.classList.contains('adstage-slot')) {
|
|
2706
|
-
containerType = 'adstage-class';
|
|
2707
|
-
}
|
|
2708
|
-
else {
|
|
2709
|
-
containerType = 'generic';
|
|
2710
|
-
}
|
|
2711
|
-
targetContainer.style.cssText += `
|
|
2712
|
-
height: 0px !important;
|
|
2713
|
-
min-height: 0px !important;
|
|
2714
|
-
padding: 0px !important;
|
|
2715
|
-
margin: 0px !important;
|
|
2716
|
-
border: none !important;
|
|
2717
|
-
overflow: hidden !important;
|
|
2718
|
-
display: block !important;
|
|
2719
|
-
`;
|
|
2720
|
-
// 내부 모든 요소 제거
|
|
2721
|
-
targetContainer.innerHTML = '';
|
|
2722
|
-
// 빈 상태임을 표시하는 속성 추가
|
|
2723
|
-
targetContainer.setAttribute('data-adstage-empty', 'true');
|
|
2724
|
-
if (this._config?.debug) {
|
|
2725
|
-
console.warn(`⚠️ Ad container collapsed (${containerType}): ${slot.id}`, targetContainer);
|
|
2726
|
-
}
|
|
2727
|
-
}
|
|
2728
|
-
else {
|
|
2729
|
-
// 컨테이너를 찾지 못한 경우 새로운 빈 컨테이너 생성
|
|
2730
|
-
this.createEmptyContainer(slot);
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
// 슬롯 상태 업데이트 (제거하지 않고 빈 상태로 마킹)
|
|
2734
|
-
slot.advertisement = undefined;
|
|
2735
|
-
slot.isEmpty = true;
|
|
2736
|
-
}
|
|
2945
|
+
// renderFallback 제거: AdRenderer.renderFallback 사용
|
|
2737
2946
|
/**
|
|
2738
2947
|
* 빈 컨테이너 생성 (컨테이너를 찾지 못한 경우)
|
|
2739
2948
|
*/
|
|
2740
|
-
createEmptyContainer
|
|
2741
|
-
const originalContainer = document.getElementById(slot.containerId);
|
|
2742
|
-
if (originalContainer) {
|
|
2743
|
-
// 기존 내용 제거
|
|
2744
|
-
originalContainer.innerHTML = '';
|
|
2745
|
-
// 빈 AdStage 컨테이너 생성
|
|
2746
|
-
const emptyElement = document.createElement('div');
|
|
2747
|
-
emptyElement.id = slot.id;
|
|
2748
|
-
emptyElement.className = 'adstage-slot adstage-empty';
|
|
2749
|
-
emptyElement.setAttribute('data-adstage-container', 'true');
|
|
2750
|
-
emptyElement.setAttribute('data-adstage-empty', 'true');
|
|
2751
|
-
emptyElement.setAttribute('data-adstage-slot', slot.id);
|
|
2752
|
-
emptyElement.style.cssText = `
|
|
2753
|
-
height: 0px !important;
|
|
2754
|
-
min-height: 0px !important;
|
|
2755
|
-
padding: 0px !important;
|
|
2756
|
-
margin: 0px !important;
|
|
2757
|
-
border: none !important;
|
|
2758
|
-
overflow: hidden !important;
|
|
2759
|
-
display: block !important;
|
|
2760
|
-
`;
|
|
2761
|
-
originalContainer.appendChild(emptyElement);
|
|
2762
|
-
if (this._config?.debug) {
|
|
2763
|
-
console.warn(`⚠️ Created empty AdStage container: ${slot.id}`);
|
|
2764
|
-
}
|
|
2765
|
-
}
|
|
2766
|
-
}
|
|
2949
|
+
// createEmptyContainer 제거: AdRenderer 내부 구현 사용
|
|
2767
2950
|
/**
|
|
2768
2951
|
* 광고 데이터 가져오기
|
|
2769
2952
|
*/
|
|
@@ -2806,228 +2989,15 @@ class AdsModule {
|
|
|
2806
2989
|
/**
|
|
2807
2990
|
* 광고 슬라이더 렌더링 (여러 광고 또는 autoSlide 옵션)
|
|
2808
2991
|
*/
|
|
2809
|
-
|
|
2810
|
-
const container = document.getElementById(slot.containerId);
|
|
2811
|
-
if (!container) {
|
|
2812
|
-
throw new Error(`Container not found: ${slot.containerId}`);
|
|
2813
|
-
}
|
|
2814
|
-
// 이벤트 추적 콜백 함수 (중복 노출 방지 포함)
|
|
2815
|
-
const trackEventCallback = async (adId, slotId, eventType) => {
|
|
2816
|
-
// 노출 이벤트인 경우 중복 확인
|
|
2817
|
-
if (eventType === AdEventType.VIEWABLE) {
|
|
2818
|
-
if (ViewableEventTracker.isDuplicateViewable(adId, slotId, this._config?.debug)) {
|
|
2819
|
-
if (this._config?.debug) {
|
|
2820
|
-
console.log(`🚫 Duplicate viewable blocked for ad ${adId} in slot ${slotId}`);
|
|
2821
|
-
}
|
|
2822
|
-
return; // 중복 노출이면 추적하지 않음
|
|
2823
|
-
}
|
|
2824
|
-
if (this._config?.debug) {
|
|
2825
|
-
console.log(`✅ New viewable recorded for ad ${adId} in slot ${slotId}`);
|
|
2826
|
-
}
|
|
2827
|
-
}
|
|
2828
|
-
// 실제 API 호출로 이벤트 전송
|
|
2829
|
-
if (this.advertisementEventTracker) {
|
|
2830
|
-
try {
|
|
2831
|
-
await this.advertisementEventTracker.trackAdvertisementEvent(adId, slotId, eventType, {} // 기본 메타데이터
|
|
2832
|
-
);
|
|
2833
|
-
if (this._config?.debug) {
|
|
2834
|
-
console.log(`📊 Advertisement event tracked: ${eventType} for ad ${adId} in slot ${slotId}`);
|
|
2835
|
-
}
|
|
2836
|
-
}
|
|
2837
|
-
catch (error) {
|
|
2838
|
-
if (this._config?.debug) {
|
|
2839
|
-
console.error(`❌ Failed to track ${eventType} event for ad ${adId}:`, error);
|
|
2840
|
-
}
|
|
2841
|
-
}
|
|
2842
|
-
}
|
|
2843
|
-
// 🆕 슬라이더에서 VIEWABLE 이벤트 발생 시 해당 광고로 ViewabilityTracker 교체
|
|
2844
|
-
if (eventType === AdEventType.VIEWABLE && advertisements.length > 1) {
|
|
2845
|
-
const currentAd = advertisements.find(ad => ad._id === adId);
|
|
2846
|
-
if (currentAd) {
|
|
2847
|
-
// 기존 ViewabilityTracker 정리
|
|
2848
|
-
if (slot.viewabilityTracker) {
|
|
2849
|
-
slot.viewabilityTracker.destroy();
|
|
2850
|
-
slot.viewabilityTracker = null;
|
|
2851
|
-
}
|
|
2852
|
-
// 현재 광고에 대해 새로운 ViewabilityTracker 시작
|
|
2853
|
-
this.startBasicViewabilityTracking(slot, currentAd);
|
|
2854
|
-
if (this._config?.debug) {
|
|
2855
|
-
console.log(`🔄 ViewabilityTracker switched to ad ${adId} in slider: ${slot.id}`);
|
|
2856
|
-
}
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
|
-
};
|
|
2860
|
-
let sliderElement;
|
|
2861
|
-
// 최적화된 슬라이더 옵션 준비
|
|
2862
|
-
const optimizedSliderOptions = {
|
|
2863
|
-
autoSlideInterval: (slot.config?.slideInterval || 5000) / 1000,
|
|
2864
|
-
...slot.config,
|
|
2865
|
-
// 🆕 동적 크기 정보 전달
|
|
2866
|
-
optimizedHeight: slot.optimizedHeight,
|
|
2867
|
-
aspectRatio: slot.aspectRatio
|
|
2868
|
-
};
|
|
2869
|
-
// 텍스트 광고는 TextTransitionManager 사용, 그 외는 CarouselSliderManager 사용
|
|
2870
|
-
if (slot.adType === AdType.TEXT) {
|
|
2871
|
-
sliderElement = TextTransitionManager.createTextTransitionContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
|
|
2872
|
-
if (this._config?.debug) {
|
|
2873
|
-
console.log(`✨ Text transition created for TEXT slot: ${slot.id} with ${advertisements.length} ads`);
|
|
2874
|
-
}
|
|
2875
|
-
}
|
|
2876
|
-
else {
|
|
2877
|
-
sliderElement = CarouselSliderManager.createSliderContainer(slot, advertisements, optimizedSliderOptions, trackEventCallback);
|
|
2878
|
-
if (this._config?.debug) {
|
|
2879
|
-
console.log(`🎠 Carousel slider created for ${slot.adType} slot: ${slot.id} with ${advertisements.length} ads (optimized: ${slot.optimizedHeight || 'default'})`);
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
|
-
// 기존 내용 제거하고 슬라이더 추가
|
|
2883
|
-
container.innerHTML = '';
|
|
2884
|
-
container.appendChild(sliderElement);
|
|
2885
|
-
// ✅ 신규: 슬라이더에서도 첫 번째 광고에 대해 ViewabilityTracker 시작
|
|
2886
|
-
if (advertisements.length > 0) {
|
|
2887
|
-
this.startBasicViewabilityTracking(slot, advertisements[0]);
|
|
2888
|
-
if (this._config?.debug) {
|
|
2889
|
-
console.log(`🎯 Viewability tracking started for first ad in slider: ${slot.id}`);
|
|
2890
|
-
}
|
|
2891
|
-
}
|
|
2892
|
-
}
|
|
2992
|
+
// renderAdSlider 제거: AdRenderer.renderAdSlider 사용
|
|
2893
2993
|
/**
|
|
2894
2994
|
* 광고 렌더링 (단일 광고용)
|
|
2895
2995
|
*/
|
|
2896
|
-
|
|
2897
|
-
if (!slot.advertisement) {
|
|
2898
|
-
throw new Error('No advertisement to render');
|
|
2899
|
-
}
|
|
2900
|
-
await this.renderAdElement(slot, slot.advertisement);
|
|
2901
|
-
slot.isLoaded = true;
|
|
2902
|
-
}
|
|
2996
|
+
// renderAd 제거: AdRenderer.renderAd 사용
|
|
2903
2997
|
/**
|
|
2904
2998
|
* 광고 요소 렌더링 (기본 구현)
|
|
2905
2999
|
*/
|
|
2906
|
-
|
|
2907
|
-
const container = document.getElementById(slot.containerId);
|
|
2908
|
-
if (!container)
|
|
2909
|
-
return;
|
|
2910
|
-
// 기본 HTML 구조 생성
|
|
2911
|
-
const adElement = document.createElement('div');
|
|
2912
|
-
adElement.className = 'adstage-ad';
|
|
2913
|
-
// 스마트한 크기 설정 - 최적화된 크기가 있으면 사용
|
|
2914
|
-
const optimizedHeight = slot.optimizedHeight;
|
|
2915
|
-
const containerElement = container.parentElement || container;
|
|
2916
|
-
if (optimizedHeight) {
|
|
2917
|
-
adElement.style.width = '100%';
|
|
2918
|
-
adElement.style.height = optimizedHeight;
|
|
2919
|
-
}
|
|
2920
|
-
else {
|
|
2921
|
-
const { width, height } = this.calculateAdSize(containerElement, slot.adType, slot.config || {});
|
|
2922
|
-
adElement.style.width = width;
|
|
2923
|
-
adElement.style.height = height;
|
|
2924
|
-
}
|
|
2925
|
-
// 광고 타입별 렌더링
|
|
2926
|
-
switch (slot.adType) {
|
|
2927
|
-
case AdType.BANNER:
|
|
2928
|
-
if (ad.imageUrl) {
|
|
2929
|
-
await this.renderOptimizedBannerImage(adElement, ad, slot);
|
|
2930
|
-
}
|
|
2931
|
-
break;
|
|
2932
|
-
case AdType.TEXT:
|
|
2933
|
-
const textDiv = document.createElement('div');
|
|
2934
|
-
textDiv.innerHTML = `
|
|
2935
|
-
<h3>${ad.title}</h3>
|
|
2936
|
-
${ad.description ? `<p>${ad.description}</p>` : ''}
|
|
2937
|
-
${ad.textContent ? `<div>${ad.textContent}</div>` : ''}
|
|
2938
|
-
`;
|
|
2939
|
-
adElement.appendChild(textDiv);
|
|
2940
|
-
break;
|
|
2941
|
-
case AdType.VIDEO:
|
|
2942
|
-
if (ad.videoUrl) {
|
|
2943
|
-
const video = document.createElement('video');
|
|
2944
|
-
video.src = ad.videoUrl;
|
|
2945
|
-
video.controls = true;
|
|
2946
|
-
video.style.width = '100%';
|
|
2947
|
-
video.style.height = '100%';
|
|
2948
|
-
adElement.appendChild(video);
|
|
2949
|
-
}
|
|
2950
|
-
break;
|
|
2951
|
-
default:
|
|
2952
|
-
adElement.innerHTML = `<div>${ad.title}</div>`;
|
|
2953
|
-
}
|
|
2954
|
-
// 클릭 이벤트 추가
|
|
2955
|
-
if (ad.linkUrl) {
|
|
2956
|
-
adElement.style.cursor = 'pointer';
|
|
2957
|
-
adElement.addEventListener('click', () => {
|
|
2958
|
-
window.open(ad.linkUrl, '_blank');
|
|
2959
|
-
});
|
|
2960
|
-
}
|
|
2961
|
-
container.innerHTML = '';
|
|
2962
|
-
container.appendChild(adElement);
|
|
2963
|
-
}
|
|
2964
|
-
/**
|
|
2965
|
-
* 최적화된 배너 이미지 렌더링
|
|
2966
|
-
*/
|
|
2967
|
-
async renderOptimizedBannerImage(adElement, ad, slot) {
|
|
2968
|
-
try {
|
|
2969
|
-
// 사용자가 크기를 지정했는지 확인
|
|
2970
|
-
const configWidth = slot.config?.width;
|
|
2971
|
-
const configHeight = slot.config?.height;
|
|
2972
|
-
const hasUserDefinedWidth = configWidth &&
|
|
2973
|
-
(typeof configWidth === 'number' || (typeof configWidth === 'string' && configWidth !== '100%'));
|
|
2974
|
-
const hasUserDefinedHeight = configHeight &&
|
|
2975
|
-
(typeof configHeight === 'number' || (typeof configHeight === 'string' && configHeight !== 'auto'));
|
|
2976
|
-
const hasUserDefinedSize = hasUserDefinedWidth || hasUserDefinedHeight;
|
|
2977
|
-
// 이미지 요소 생성
|
|
2978
|
-
const img = document.createElement('img');
|
|
2979
|
-
img.src = ad.imageUrl;
|
|
2980
|
-
img.alt = ad.title;
|
|
2981
|
-
if (hasUserDefinedSize) {
|
|
2982
|
-
// 🎯 사용자가 크기를 지정한 경우: 컨테이너에 꽉 차도록 설정
|
|
2983
|
-
img.style.width = '100%';
|
|
2984
|
-
img.style.height = '100%';
|
|
2985
|
-
img.style.objectFit = 'cover'; // 컨테이너에 꽉 찬 상태로 비율 유지
|
|
2986
|
-
img.style.objectPosition = 'center';
|
|
2987
|
-
if (this._config?.debug) {
|
|
2988
|
-
console.log(`🎯 User-defined size detected: filling container completely`);
|
|
2989
|
-
}
|
|
2990
|
-
}
|
|
2991
|
-
else {
|
|
2992
|
-
// 사용자가 크기를 지정하지 않은 경우: 동적 최적화 적용
|
|
2993
|
-
const imageDimensions = await this.loadImageDimensions(ad.imageUrl);
|
|
2994
|
-
const imageAspectRatio = imageDimensions.width / imageDimensions.height;
|
|
2995
|
-
const containerAspectRatio = slot.aspectRatio || 16 / 9;
|
|
2996
|
-
img.style.width = '100%';
|
|
2997
|
-
img.style.height = '100%';
|
|
2998
|
-
// 🎨 최적화된 스타일 적용
|
|
2999
|
-
this.applyOptimizedImageStyle(img, imageAspectRatio, containerAspectRatio);
|
|
3000
|
-
if (this._config?.debug) {
|
|
3001
|
-
console.log(`🖼️ Optimized banner image loaded: ${imageDimensions.width}x${imageDimensions.height} (ratio: ${imageAspectRatio.toFixed(2)})`);
|
|
3002
|
-
}
|
|
3003
|
-
}
|
|
3004
|
-
// 이미지 로드 완료 처리
|
|
3005
|
-
img.onload = () => {
|
|
3006
|
-
if (this._config?.debug) {
|
|
3007
|
-
console.log(`✅ Banner image loaded successfully`);
|
|
3008
|
-
}
|
|
3009
|
-
};
|
|
3010
|
-
// 에러 처리
|
|
3011
|
-
img.onerror = () => {
|
|
3012
|
-
console.warn(`Failed to load banner image: ${ad.imageUrl}`);
|
|
3013
|
-
// 폴백 텍스트 표시
|
|
3014
|
-
adElement.innerHTML = `<div style="display: flex; align-items: center; justify-content: center; background: #f0f0f0; color: #666;">${ad.title}</div>`;
|
|
3015
|
-
};
|
|
3016
|
-
adElement.appendChild(img);
|
|
3017
|
-
}
|
|
3018
|
-
catch (error) {
|
|
3019
|
-
console.warn('Failed to optimize banner image, using fallback:', error);
|
|
3020
|
-
// 기본 이미지 렌더링 (폴백)
|
|
3021
|
-
const img = document.createElement('img');
|
|
3022
|
-
img.src = ad.imageUrl;
|
|
3023
|
-
img.alt = ad.title;
|
|
3024
|
-
img.style.width = '100%';
|
|
3025
|
-
img.style.height = '100%';
|
|
3026
|
-
img.style.objectFit = 'cover';
|
|
3027
|
-
img.style.objectPosition = 'center'; // 🎯 폴백 이미지도 중앙 정렬
|
|
3028
|
-
adElement.appendChild(img);
|
|
3029
|
-
}
|
|
3030
|
-
}
|
|
3000
|
+
// renderAdElement 제거: AdRenderer.renderAdElement 사용
|
|
3031
3001
|
/**
|
|
3032
3002
|
* 광고 슬롯 새로고침
|
|
3033
3003
|
*/
|
|
@@ -3037,7 +3007,7 @@ class AdsModule {
|
|
|
3037
3007
|
const newAdData = await this.fetchAdData(slot.adType, slot.config || {});
|
|
3038
3008
|
if (newAdData && newAdData.length > 0) {
|
|
3039
3009
|
slot.advertisement = newAdData[0]; // 첫 번째 광고로 업데이트
|
|
3040
|
-
await this.renderAd(slot);
|
|
3010
|
+
await this.adRenderer?.renderAd(slot);
|
|
3041
3011
|
// 새로운 노출 추적
|
|
3042
3012
|
if (this.advertisementEventTracker) {
|
|
3043
3013
|
console.log('New advertisement viewable tracked for slot:', slot.id);
|
|
@@ -3284,13 +3254,6 @@ class AdStage {
|
|
|
3284
3254
|
productionMode: false,
|
|
3285
3255
|
...config
|
|
3286
3256
|
};
|
|
3287
|
-
// baseUrl이 설정되었으면 endpoints에 적용
|
|
3288
|
-
if (config.baseUrl) {
|
|
3289
|
-
endpoints.setBaseUrl(config.baseUrl);
|
|
3290
|
-
if (config.debug) {
|
|
3291
|
-
console.log('🔄 API base URL set to:', config.baseUrl);
|
|
3292
|
-
}
|
|
3293
|
-
}
|
|
3294
3257
|
// 모듈 동기 초기화
|
|
3295
3258
|
const enabledModules = instance._config.modules || ['ads', 'events', 'config'];
|
|
3296
3259
|
for (const moduleName of enabledModules) {
|
|
@@ -3353,82 +3316,6 @@ class AdStage {
|
|
|
3353
3316
|
}
|
|
3354
3317
|
}
|
|
3355
3318
|
|
|
3356
|
-
const AdStageContext = react.createContext(null);
|
|
3357
|
-
function AdStageProvider({ children, config }) {
|
|
3358
|
-
const [isInitialized, setIsInitialized] = react.useState(false);
|
|
3359
|
-
const [currentConfig, setCurrentConfig] = react.useState(null);
|
|
3360
|
-
const [error, setError] = react.useState(null);
|
|
3361
|
-
const initialize = (newConfig) => {
|
|
3362
|
-
try {
|
|
3363
|
-
setError(null);
|
|
3364
|
-
// 기존 인스턴스가 있으면 리셋
|
|
3365
|
-
if (isInitialized) {
|
|
3366
|
-
AdStage.reset();
|
|
3367
|
-
}
|
|
3368
|
-
AdStage.init(newConfig);
|
|
3369
|
-
setCurrentConfig(newConfig);
|
|
3370
|
-
setIsInitialized(true);
|
|
3371
|
-
if (newConfig.debug) {
|
|
3372
|
-
console.log('✅ AdStage SDK initialized successfully via React Provider');
|
|
3373
|
-
}
|
|
3374
|
-
}
|
|
3375
|
-
catch (err) {
|
|
3376
|
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
3377
|
-
setError(errorMessage);
|
|
3378
|
-
console.error('❌ AdStage SDK initialization failed:', err);
|
|
3379
|
-
setIsInitialized(false);
|
|
3380
|
-
setCurrentConfig(null);
|
|
3381
|
-
}
|
|
3382
|
-
};
|
|
3383
|
-
const reset = () => {
|
|
3384
|
-
try {
|
|
3385
|
-
AdStage.reset();
|
|
3386
|
-
setIsInitialized(false);
|
|
3387
|
-
setCurrentConfig(null);
|
|
3388
|
-
setError(null);
|
|
3389
|
-
}
|
|
3390
|
-
catch (err) {
|
|
3391
|
-
console.error('❌ AdStage SDK reset failed:', err);
|
|
3392
|
-
}
|
|
3393
|
-
};
|
|
3394
|
-
// 자동 초기화
|
|
3395
|
-
react.useEffect(() => {
|
|
3396
|
-
if (config && !isInitialized) {
|
|
3397
|
-
initialize(config);
|
|
3398
|
-
}
|
|
3399
|
-
}, [config, isInitialized]);
|
|
3400
|
-
const contextValue = {
|
|
3401
|
-
isInitialized,
|
|
3402
|
-
config: currentConfig,
|
|
3403
|
-
initialize,
|
|
3404
|
-
reset,
|
|
3405
|
-
error
|
|
3406
|
-
};
|
|
3407
|
-
return (jsxRuntime.jsx(AdStageContext.Provider, { value: contextValue, children: children }));
|
|
3408
|
-
}
|
|
3409
|
-
/**
|
|
3410
|
-
* AdStage Context Hook
|
|
3411
|
-
* AdStageProvider 내에서 SDK 상태에 접근할 수 있습니다.
|
|
3412
|
-
*/
|
|
3413
|
-
function useAdStage() {
|
|
3414
|
-
const context = react.useContext(AdStageContext);
|
|
3415
|
-
if (!context) {
|
|
3416
|
-
throw new Error('useAdStage must be used within an AdStageProvider');
|
|
3417
|
-
}
|
|
3418
|
-
return context;
|
|
3419
|
-
}
|
|
3420
|
-
/**
|
|
3421
|
-
* AdStage SDK Hook
|
|
3422
|
-
* 초기화된 AdStage 인스턴스에 직접 접근할 수 있습니다.
|
|
3423
|
-
*/
|
|
3424
|
-
function useAdStageSDK() {
|
|
3425
|
-
const { isInitialized } = useAdStage();
|
|
3426
|
-
if (!isInitialized) {
|
|
3427
|
-
throw new Error('AdStage SDK is not initialized. Please call initialize() first or provide config to AdStageProvider.');
|
|
3428
|
-
}
|
|
3429
|
-
return AdStage;
|
|
3430
|
-
}
|
|
3431
|
-
|
|
3432
3319
|
/**
|
|
3433
3320
|
* AdStage Web SDK
|
|
3434
3321
|
* 네임스페이스 아키텍처 기반 SDK
|
|
@@ -3439,8 +3326,5 @@ const SDK_VERSION = '2.0.0';
|
|
|
3439
3326
|
const SUPPORTED_MODULES = ['ads', 'events', 'config'];
|
|
3440
3327
|
|
|
3441
3328
|
exports.AdStage = AdStage;
|
|
3442
|
-
exports.AdStageProvider = AdStageProvider;
|
|
3443
3329
|
exports.SDK_VERSION = SDK_VERSION;
|
|
3444
3330
|
exports.SUPPORTED_MODULES = SUPPORTED_MODULES;
|
|
3445
|
-
exports.useAdStage = useAdStage;
|
|
3446
|
-
exports.useAdStageSDK = useAdStageSDK;
|