@adstage/web-sdk 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/index.cjs.js +2304 -0
  4. package/dist/index.d.ts +416 -0
  5. package/dist/index.esm.js +2288 -0
  6. package/dist/index.standalone.js +2331 -0
  7. package/examples/README.md +33 -0
  8. package/examples/banner-ads.html +512 -0
  9. package/examples/index.html +338 -0
  10. package/examples/native-ads.html +634 -0
  11. package/examples/react-app/README.md +70 -0
  12. package/examples/react-app/index.html +13 -0
  13. package/examples/react-app/package-lock.json +3042 -0
  14. package/examples/react-app/package.json +26 -0
  15. package/examples/react-app/pnpm-lock.yaml +1857 -0
  16. package/examples/react-app/public/index.standalone.js +2331 -0
  17. package/examples/react-app/src/App.tsx +226 -0
  18. package/examples/react-app/src/index.css +37 -0
  19. package/examples/react-app/src/main.tsx +10 -0
  20. package/examples/react-app/tsconfig.json +25 -0
  21. package/examples/react-app/tsconfig.node.json +10 -0
  22. package/examples/react-app/vite.config.ts +15 -0
  23. package/examples/react-nextjs/app/globals.css +200 -0
  24. package/examples/react-nextjs/app/layout.tsx +27 -0
  25. package/examples/react-nextjs/app/page.tsx +258 -0
  26. package/examples/react-nextjs/next.config.js +9 -0
  27. package/examples/react-nextjs/package.json +22 -0
  28. package/examples/react-nextjs/pnpm-lock.yaml +343 -0
  29. package/examples/react-nextjs/tsconfig.json +34 -0
  30. package/examples/text-ads.html +597 -0
  31. package/examples/video-ads.html +739 -0
  32. package/package.json +83 -0
  33. package/src/global.d.ts +20 -0
  34. package/src/index.ts +350 -0
  35. package/src/managers/device-info-collector.ts +127 -0
  36. package/src/managers/event-tracker.ts +131 -0
  37. package/src/managers/fade-slider-manager.ts +276 -0
  38. package/src/managers/impression-tracker.ts +88 -0
  39. package/src/managers/slider-manager.ts +405 -0
  40. package/src/react/components/AdErrorBoundary.tsx +75 -0
  41. package/src/react/components/AdSlot.tsx +144 -0
  42. package/src/react/components/BannerAd.tsx +24 -0
  43. package/src/react/components/InterstitialAd.tsx +24 -0
  44. package/src/react/components/NativeAd.tsx +24 -0
  45. package/src/react/components/TextAd.tsx +24 -0
  46. package/src/react/components/VideoAd.tsx +24 -0
  47. package/src/react/components/index.ts +8 -0
  48. package/src/react/hooks/index.ts +4 -0
  49. package/src/react/hooks/useAdSlot.ts +83 -0
  50. package/src/react/hooks/useAdStage.ts +14 -0
  51. package/src/react/hooks/useAdTracking.ts +61 -0
  52. package/src/react/index.ts +4 -0
  53. package/src/react/providers/AdStageProvider.tsx +86 -0
  54. package/src/react/providers/index.ts +2 -0
  55. package/src/renderers/banner-renderer.ts +35 -0
  56. package/src/renderers/base-renderer.ts +207 -0
  57. package/src/renderers/index.ts +71 -0
  58. package/src/renderers/interstitial-renderer.ts +70 -0
  59. package/src/renderers/native-renderer.ts +35 -0
  60. package/src/renderers/text-renderer.ts +94 -0
  61. package/src/renderers/video-renderer.ts +63 -0
  62. package/src/types/advertisement.ts +197 -0
  63. package/src/types/api.ts +173 -0
  64. package/src/types/config.ts +174 -0
  65. package/src/types/events.ts +60 -0
  66. package/src/types/index.ts +6 -0
  67. package/src/utils/dom-utils.ts +237 -0
  68. package/src/utils/sdk-utils.ts +134 -0
@@ -0,0 +1,276 @@
1
+ import { AdType, AdEventType } from '../types/advertisement';
2
+ import type { AdSlot, Advertisement } from '../types/advertisement';
3
+ import { AdRendererFactory } from '../renderers';
4
+
5
+ /**
6
+ * 페이드 슬라이더 관리 클래스
7
+ * - 텍스트 광고 전용 페이드 인/아웃 슬라이더
8
+ * - 상하 교차 효과 (위에서 아래로, 아래서 위로)
9
+ * - 무한 루프 지원
10
+ */
11
+ export class FadeSliderManager {
12
+ /**
13
+ * 페이드 슬라이더 컨테이너 생성
14
+ */
15
+ static createFadeSliderContainer(
16
+ slot: AdSlot,
17
+ advertisements: Advertisement[],
18
+ options: any,
19
+ trackEventCallback: (adId: string, slotId: string, eventType: AdEventType) => void
20
+ ): HTMLElement {
21
+ const sliderWrapper = document.createElement('div');
22
+ sliderWrapper.className = 'adstage-fade-slider-wrapper';
23
+
24
+ // 래퍼 스타일 설정
25
+ const containerStyles: Record<string, string> = {
26
+ position: 'relative',
27
+ overflow: 'hidden',
28
+ display: 'inline-block',
29
+ };
30
+
31
+ // 사용자가 크기를 지정한 경우
32
+ if (slot.width && slot.width !== 0) {
33
+ let width: string;
34
+ if (typeof slot.width === 'string') {
35
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
36
+ } else {
37
+ width = `${slot.width}px`;
38
+ }
39
+ containerStyles.width = width;
40
+ }
41
+
42
+ if (slot.height && slot.height !== 0) {
43
+ let height: string;
44
+ if (typeof slot.height === 'string') {
45
+ height = slot.height.includes('px') || slot.height.includes('%') ? slot.height : `${slot.height}px`;
46
+ } else {
47
+ height = `${slot.height}px`;
48
+ }
49
+ containerStyles.height = height;
50
+ }
51
+
52
+ // 스타일 적용
53
+ Object.entries(containerStyles).forEach(([key, value]) => {
54
+ sliderWrapper.style.setProperty(key, value);
55
+ });
56
+
57
+ // 슬라이드 컨테이너
58
+ const slideContainer = document.createElement('div');
59
+ slideContainer.className = 'adstage-fade-slide-container';
60
+ slideContainer.style.cssText = `
61
+ position: relative;
62
+ width: 100%;
63
+ height: 100%;
64
+ `;
65
+
66
+ // 크기 측정을 위한 임시 컨테이너 (자동 크기 계산이 필요한 경우)
67
+ let measureContainer: HTMLElement | null = null;
68
+ const needsWidthMeasurement = !slot.width || slot.width === 0;
69
+ const needsHeightMeasurement = !slot.height || slot.height === 0;
70
+
71
+ if (needsWidthMeasurement || needsHeightMeasurement) {
72
+ measureContainer = document.createElement('div');
73
+ measureContainer.style.cssText = `
74
+ position: absolute;
75
+ visibility: hidden;
76
+ white-space: nowrap;
77
+ top: -9999px;
78
+ left: -9999px;
79
+ `;
80
+
81
+ // width가 설정되어 있으면 측정 컨테이너에도 적용
82
+ if (!needsWidthMeasurement && slot.width) {
83
+ let width: string;
84
+ if (typeof slot.width === 'string') {
85
+ width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
86
+ } else {
87
+ width = `${slot.width}px`;
88
+ }
89
+ measureContainer.style.width = width;
90
+ measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
91
+ }
92
+
93
+ document.body.appendChild(measureContainer);
94
+
95
+ let maxWidth = 0;
96
+ let maxHeight = 0;
97
+
98
+ // 모든 광고의 크기를 측정하여 최대 크기 찾기
99
+ advertisements.forEach(ad => {
100
+ const measureAdElement = AdRendererFactory.render(
101
+ ad,
102
+ slot,
103
+ trackEventCallback
104
+ );
105
+ measureContainer!.appendChild(measureAdElement);
106
+
107
+ const rect = measureAdElement.getBoundingClientRect();
108
+ if (rect.width > maxWidth) maxWidth = rect.width;
109
+ if (rect.height > maxHeight) maxHeight = rect.height;
110
+
111
+ // 측정 후 요소 제거
112
+ measureContainer!.removeChild(measureAdElement);
113
+ });
114
+
115
+ // 측정된 최대 크기로 래퍼 크기 설정
116
+ if (needsWidthMeasurement && maxWidth > 0) {
117
+ sliderWrapper.style.width = `${maxWidth}px`;
118
+ }
119
+
120
+ if (needsHeightMeasurement && maxHeight > 0) {
121
+ sliderWrapper.style.height = `${maxHeight}px`;
122
+ }
123
+
124
+ // 측정 컨테이너 제거
125
+ document.body.removeChild(measureContainer);
126
+ }
127
+
128
+ // 각 광고를 슬라이드로 생성
129
+ const slideElements: HTMLElement[] = [];
130
+ advertisements.forEach((ad, index) => {
131
+ const slideElement = document.createElement('div');
132
+ slideElement.className = 'adstage-fade-slide';
133
+ slideElement.style.cssText = `
134
+ position: absolute;
135
+ top: 0;
136
+ left: 0;
137
+ width: 100%;
138
+ height: 100%;
139
+ display: flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ opacity: ${index === 0 ? '1' : '0'};
143
+ transform: translateY(${index === 0 ? '0' : '20px'});
144
+ transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
145
+ z-index: ${index === 0 ? '2' : '1'};
146
+ `;
147
+
148
+ // 광고 렌더링
149
+ const adElement = AdRendererFactory.render(
150
+ ad,
151
+ slot,
152
+ trackEventCallback
153
+ );
154
+
155
+ slideElement.appendChild(adElement);
156
+ slideContainer.appendChild(slideElement);
157
+ slideElements.push(slideElement);
158
+ });
159
+
160
+ // 슬라이더 상태 관리
161
+ let currentSlide = 0;
162
+ const totalSlides = advertisements.length;
163
+ const autoSlideInterval = (options?.autoSlideInterval || 4) * 1000; // 기본 4초 (페이드는 조금 더 길게)
164
+
165
+ // 슬라이드 이동 함수 (페이드 효과)
166
+ const moveToSlide = (index: number) => {
167
+ if (index >= totalSlides) {
168
+ index = 0; // 무한 루프
169
+ } else if (index < 0) {
170
+ index = totalSlides - 1;
171
+ }
172
+
173
+ const previousSlide = slideElements[currentSlide];
174
+ const nextSlide = slideElements[index];
175
+
176
+ // 이전 슬라이드 페이드 아웃 (아래로)
177
+ previousSlide.style.opacity = '0';
178
+ previousSlide.style.transform = 'translateY(-20px)';
179
+ previousSlide.style.zIndex = '1';
180
+
181
+ // 다음 슬라이드 페이드 인 (위에서)
182
+ nextSlide.style.opacity = '1';
183
+ nextSlide.style.transform = 'translateY(0)';
184
+ nextSlide.style.zIndex = '2';
185
+
186
+ // 다른 슬라이드들은 숨김
187
+ slideElements.forEach((slide, i) => {
188
+ if (i !== index && i !== currentSlide) {
189
+ slide.style.opacity = '0';
190
+ slide.style.transform = 'translateY(20px)';
191
+ slide.style.zIndex = '1';
192
+ }
193
+ });
194
+
195
+ currentSlide = index;
196
+
197
+ // 현재 슬라이드의 광고에 대해 노출 이벤트 추적
198
+ if (currentSlide > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
199
+ trackEventCallback(advertisements[currentSlide]._id, slot.id, AdEventType.IMPRESSION);
200
+ }
201
+ };
202
+
203
+ // 자동 슬라이드
204
+ let autoSlideTimer = setInterval(() => {
205
+ const nextIndex = currentSlide + 1;
206
+ moveToSlide(nextIndex);
207
+ }, autoSlideInterval);
208
+
209
+ // 마우스 호버 시 자동 슬라이드 일시정지
210
+ sliderWrapper.addEventListener('mouseenter', () => {
211
+ clearInterval(autoSlideTimer);
212
+ });
213
+
214
+ sliderWrapper.addEventListener('mouseleave', () => {
215
+ autoSlideTimer = setInterval(() => {
216
+ const nextIndex = currentSlide + 1;
217
+ moveToSlide(nextIndex);
218
+ }, autoSlideInterval);
219
+ });
220
+
221
+ // 터치 제스처 지원
222
+ FadeSliderManager.addTouchSupport(sliderWrapper, moveToSlide, () => currentSlide, totalSlides);
223
+
224
+ // 요소들 조립
225
+ sliderWrapper.appendChild(slideContainer);
226
+
227
+ return sliderWrapper;
228
+ }
229
+
230
+ /**
231
+ * 터치 제스처 지원 추가
232
+ */
233
+ private static addTouchSupport(
234
+ container: HTMLElement,
235
+ moveToSlide: (index: number) => void,
236
+ getCurrentSlide: () => number,
237
+ totalSlides: number
238
+ ): void {
239
+ let startX = 0;
240
+ let startY = 0;
241
+ let isDragging = false;
242
+
243
+ container.addEventListener('touchstart', (e) => {
244
+ startX = e.touches[0].clientX;
245
+ startY = e.touches[0].clientY;
246
+ isDragging = true;
247
+ });
248
+
249
+ container.addEventListener('touchmove', (e) => {
250
+ if (!isDragging) return;
251
+ e.preventDefault();
252
+ });
253
+
254
+ container.addEventListener('touchend', (e) => {
255
+ if (!isDragging) return;
256
+ isDragging = false;
257
+
258
+ const endX = e.changedTouches[0].clientX;
259
+ const endY = e.changedTouches[0].clientY;
260
+ const diffX = startX - endX;
261
+ const diffY = startY - endY;
262
+
263
+ // 가로 스와이프가 세로 스와이프보다 클 때만 처리
264
+ if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
265
+ const currentSlide = getCurrentSlide();
266
+ if (diffX > 0) {
267
+ // 왼쪽으로 스와이프 (다음 슬라이드)
268
+ moveToSlide(currentSlide + 1);
269
+ } else {
270
+ // 오른쪽으로 스와이프 (이전 슬라이드)
271
+ moveToSlide(currentSlide - 1);
272
+ }
273
+ }
274
+ });
275
+ }
276
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 노출 추적 및 중복 방지 관리 클래스
3
+ * - 메모리 기반 중복 확인
4
+ * - 세션 스토리지 기반 영구 추적
5
+ * - 자동 정리 기능
6
+ */
7
+ export class ImpressionTracker {
8
+ private static impressionTracker = new Map<string, number>();
9
+ private static readonly IMPRESSION_COOLDOWN = 300000; // 5분 쿨다운
10
+
11
+ /**
12
+ * 중복 노출 여부 확인
13
+ */
14
+ static isDuplicateImpression(adId: string, slotId: string, debug = false): boolean {
15
+ const key = `${adId}_${slotId}`;
16
+ const now = Date.now();
17
+
18
+ // 메모리 기반 중복 확인 (새로고침 시 초기화됨)
19
+ const lastImpression = ImpressionTracker.impressionTracker.get(key);
20
+ if (lastImpression && (now - lastImpression) < ImpressionTracker.IMPRESSION_COOLDOWN) {
21
+ if (debug) {
22
+ console.log(`Duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - lastImpression)) / 1000)}s remaining`);
23
+ }
24
+ return true;
25
+ }
26
+
27
+ // 세션 스토리지 기반 중복 확인 (새로고침 시에도 유지)
28
+ const sessionKey = `adstage_impression_${key}`;
29
+ const sessionImpression = sessionStorage.getItem(sessionKey);
30
+ if (sessionImpression) {
31
+ const sessionTime = parseInt(sessionImpression, 10);
32
+ if (!isNaN(sessionTime) && (now - sessionTime) < ImpressionTracker.IMPRESSION_COOLDOWN) {
33
+ if (debug) {
34
+ console.log(`Session-based duplicate impression blocked for ad ${adId} in slot ${slotId}. Cooldown: ${Math.round((ImpressionTracker.IMPRESSION_COOLDOWN - (now - sessionTime)) / 1000)}s remaining`);
35
+ }
36
+ // 메모리에도 기록하여 이후 요청 최적화
37
+ ImpressionTracker.impressionTracker.set(key, sessionTime);
38
+ return true;
39
+ }
40
+ }
41
+
42
+ // 노출 시점 기록 (메모리 + 세션 스토리지)
43
+ ImpressionTracker.impressionTracker.set(key, now);
44
+ sessionStorage.setItem(sessionKey, now.toString());
45
+
46
+ // 오래된 세션 스토리지 데이터 정리 (선택적)
47
+ ImpressionTracker.cleanupOldImpressions();
48
+
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * 오래된 노출 추적 데이터 정리
54
+ */
55
+ private static cleanupOldImpressions(): void {
56
+ const now = Date.now();
57
+ const cleanupThreshold = ImpressionTracker.IMPRESSION_COOLDOWN * 2; // 쿨다운의 2배 시간이 지난 데이터 정리
58
+
59
+ // 세션 스토리지 정리
60
+ for (let i = 0; i < sessionStorage.length; i++) {
61
+ const key = sessionStorage.key(i);
62
+ if (key && key.startsWith('adstage_impression_')) {
63
+ const timestamp = sessionStorage.getItem(key);
64
+ if (timestamp) {
65
+ const time = parseInt(timestamp, 10);
66
+ if (!isNaN(time) && (now - time) > cleanupThreshold) {
67
+ sessionStorage.removeItem(key);
68
+ i--; // 인덱스 조정
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ // 메모리 정리
75
+ for (const [key, timestamp] of ImpressionTracker.impressionTracker.entries()) {
76
+ if ((now - timestamp) > cleanupThreshold) {
77
+ ImpressionTracker.impressionTracker.delete(key);
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 모든 추적 데이터 정리
84
+ */
85
+ static clear(): void {
86
+ ImpressionTracker.impressionTracker.clear();
87
+ }
88
+ }