@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/index.cjs.js +2304 -0
- package/dist/index.d.ts +416 -0
- package/dist/index.esm.js +2288 -0
- package/dist/index.standalone.js +2331 -0
- package/examples/README.md +33 -0
- package/examples/banner-ads.html +512 -0
- package/examples/index.html +338 -0
- package/examples/native-ads.html +634 -0
- package/examples/react-app/README.md +70 -0
- package/examples/react-app/index.html +13 -0
- package/examples/react-app/package-lock.json +3042 -0
- package/examples/react-app/package.json +26 -0
- package/examples/react-app/pnpm-lock.yaml +1857 -0
- package/examples/react-app/public/index.standalone.js +2331 -0
- package/examples/react-app/src/App.tsx +226 -0
- package/examples/react-app/src/index.css +37 -0
- package/examples/react-app/src/main.tsx +10 -0
- package/examples/react-app/tsconfig.json +25 -0
- package/examples/react-app/tsconfig.node.json +10 -0
- package/examples/react-app/vite.config.ts +15 -0
- package/examples/react-nextjs/app/globals.css +200 -0
- package/examples/react-nextjs/app/layout.tsx +27 -0
- package/examples/react-nextjs/app/page.tsx +258 -0
- package/examples/react-nextjs/next.config.js +9 -0
- package/examples/react-nextjs/package.json +22 -0
- package/examples/react-nextjs/pnpm-lock.yaml +343 -0
- package/examples/react-nextjs/tsconfig.json +34 -0
- package/examples/text-ads.html +597 -0
- package/examples/video-ads.html +739 -0
- package/package.json +83 -0
- package/src/global.d.ts +20 -0
- package/src/index.ts +350 -0
- package/src/managers/device-info-collector.ts +127 -0
- package/src/managers/event-tracker.ts +131 -0
- package/src/managers/fade-slider-manager.ts +276 -0
- package/src/managers/impression-tracker.ts +88 -0
- package/src/managers/slider-manager.ts +405 -0
- package/src/react/components/AdErrorBoundary.tsx +75 -0
- package/src/react/components/AdSlot.tsx +144 -0
- package/src/react/components/BannerAd.tsx +24 -0
- package/src/react/components/InterstitialAd.tsx +24 -0
- package/src/react/components/NativeAd.tsx +24 -0
- package/src/react/components/TextAd.tsx +24 -0
- package/src/react/components/VideoAd.tsx +24 -0
- package/src/react/components/index.ts +8 -0
- package/src/react/hooks/index.ts +4 -0
- package/src/react/hooks/useAdSlot.ts +83 -0
- package/src/react/hooks/useAdStage.ts +14 -0
- package/src/react/hooks/useAdTracking.ts +61 -0
- package/src/react/index.ts +4 -0
- package/src/react/providers/AdStageProvider.tsx +86 -0
- package/src/react/providers/index.ts +2 -0
- package/src/renderers/banner-renderer.ts +35 -0
- package/src/renderers/base-renderer.ts +207 -0
- package/src/renderers/index.ts +71 -0
- package/src/renderers/interstitial-renderer.ts +70 -0
- package/src/renderers/native-renderer.ts +35 -0
- package/src/renderers/text-renderer.ts +94 -0
- package/src/renderers/video-renderer.ts +63 -0
- package/src/types/advertisement.ts +197 -0
- package/src/types/api.ts +173 -0
- package/src/types/config.ts +174 -0
- package/src/types/events.ts +60 -0
- package/src/types/index.ts +6 -0
- package/src/utils/dom-utils.ts +237 -0
- package/src/utils/sdk-utils.ts +134 -0
|
@@ -0,0 +1,405 @@
|
|
|
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 SliderManager {
|
|
12
|
+
/**
|
|
13
|
+
* 슬라이더 컨테이너 생성
|
|
14
|
+
*/
|
|
15
|
+
static createSliderContainer(
|
|
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-slider-wrapper';
|
|
23
|
+
|
|
24
|
+
// 사용자 지정 크기가 있으면 적용, 없으면 콘텐츠 크기에 맞춤
|
|
25
|
+
const containerStyles: Record<string, string> = {
|
|
26
|
+
position: 'relative',
|
|
27
|
+
overflow: 'hidden',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// 사용자가 크기를 지정한 경우
|
|
31
|
+
if (slot.width && slot.width !== 0) {
|
|
32
|
+
let width: string;
|
|
33
|
+
if (typeof slot.width === 'string') {
|
|
34
|
+
// 문자열인 경우 px 단위가 있는지 확인
|
|
35
|
+
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
36
|
+
} else {
|
|
37
|
+
// 숫자인 경우 px 단위 추가
|
|
38
|
+
width = `${slot.width}px`;
|
|
39
|
+
}
|
|
40
|
+
containerStyles.width = width;
|
|
41
|
+
containerStyles.display = 'inline-block'; // 지정된 크기에 맞춤 (좌측 정렬)
|
|
42
|
+
} else {
|
|
43
|
+
// 컨텐츠 크기에 맞춤
|
|
44
|
+
containerStyles.display = 'inline-block';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (slot.height && slot.height !== 0) {
|
|
48
|
+
const height = typeof slot.height === 'string' ? slot.height : `${slot.height}px`;
|
|
49
|
+
containerStyles.height = height;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 스타일 적용
|
|
53
|
+
Object.entries(containerStyles).forEach(([key, value]) => {
|
|
54
|
+
sliderWrapper.style.setProperty(key, value);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 크기 측정 (width나 height가 설정되지 않은 경우)
|
|
58
|
+
const needsWidthMeasurement = !slot.width || slot.width === 0;
|
|
59
|
+
const needsHeightMeasurement = !slot.height || slot.height === 0;
|
|
60
|
+
|
|
61
|
+
if (needsWidthMeasurement || needsHeightMeasurement) {
|
|
62
|
+
const measureContainer = document.createElement('div');
|
|
63
|
+
measureContainer.style.cssText = `
|
|
64
|
+
position: absolute;
|
|
65
|
+
visibility: hidden;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
top: -9999px;
|
|
68
|
+
left: -9999px;
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
// width가 설정되어 있으면 측정 컨테이너에도 적용
|
|
72
|
+
if (!needsWidthMeasurement && slot.width) {
|
|
73
|
+
let width: string;
|
|
74
|
+
if (typeof slot.width === 'string') {
|
|
75
|
+
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
|
|
76
|
+
} else {
|
|
77
|
+
width = `${slot.width}px`;
|
|
78
|
+
}
|
|
79
|
+
measureContainer.style.width = width;
|
|
80
|
+
measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
document.body.appendChild(measureContainer);
|
|
84
|
+
|
|
85
|
+
let maxWidth = 0;
|
|
86
|
+
let maxHeight = 0;
|
|
87
|
+
|
|
88
|
+
// 모든 광고의 크기를 측정하여 최대 크기 찾기
|
|
89
|
+
advertisements.forEach(ad => {
|
|
90
|
+
const measureAdElement = AdRendererFactory.render(
|
|
91
|
+
ad,
|
|
92
|
+
slot,
|
|
93
|
+
trackEventCallback
|
|
94
|
+
);
|
|
95
|
+
measureContainer.appendChild(measureAdElement);
|
|
96
|
+
|
|
97
|
+
const rect = measureAdElement.getBoundingClientRect();
|
|
98
|
+
if (rect.width > maxWidth) maxWidth = rect.width;
|
|
99
|
+
if (rect.height > maxHeight) maxHeight = rect.height;
|
|
100
|
+
|
|
101
|
+
// 측정 후 요소 제거
|
|
102
|
+
measureContainer.removeChild(measureAdElement);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// 측정된 최대 크기로 래퍼 크기 설정
|
|
106
|
+
if (needsWidthMeasurement && maxWidth > 0) {
|
|
107
|
+
sliderWrapper.style.width = `${maxWidth}px`;
|
|
108
|
+
containerStyles.width = `${maxWidth}px`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (needsHeightMeasurement && maxHeight > 0) {
|
|
112
|
+
sliderWrapper.style.height = `${maxHeight}px`;
|
|
113
|
+
containerStyles.height = `${maxHeight}px`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 측정 컨테이너 제거
|
|
117
|
+
document.body.removeChild(measureContainer);
|
|
118
|
+
}
|
|
119
|
+
// 무한 루프를 위해 첫 번째 슬라이드를 마지막에 복사
|
|
120
|
+
const extendedAds = [...advertisements, advertisements[0]];
|
|
121
|
+
|
|
122
|
+
// 슬라이드 컨테이너
|
|
123
|
+
const slideContainer = document.createElement('div');
|
|
124
|
+
slideContainer.className = 'adstage-slide-container';
|
|
125
|
+
|
|
126
|
+
// 슬라이드 컨테이너 스타일 - 항상 기본 설정 적용
|
|
127
|
+
const slideContainerStyles: Record<string, string> = {
|
|
128
|
+
display: 'flex',
|
|
129
|
+
transition: 'transform 0.4s ease-out',
|
|
130
|
+
width: `${extendedAds.length * 100}%`,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (slot.height && slot.height !== 0) {
|
|
134
|
+
slideContainerStyles.height = '100%';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Object.entries(slideContainerStyles).forEach(([key, value]) => {
|
|
138
|
+
slideContainer.style.setProperty(key, value);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 각 광고를 슬라이드로 생성 (복사된 첫 번째 포함)
|
|
142
|
+
extendedAds.forEach((ad, index) => {
|
|
143
|
+
const slideElement = document.createElement('div');
|
|
144
|
+
slideElement.className = 'adstage-slide';
|
|
145
|
+
|
|
146
|
+
// 슬라이드 스타일 설정 - 항상 균등 분할
|
|
147
|
+
const slideStyles: Record<string, string> = {
|
|
148
|
+
width: `${100 / extendedAds.length}%`,
|
|
149
|
+
'flex-shrink': '0',
|
|
150
|
+
display: 'flex',
|
|
151
|
+
'align-items': 'center',
|
|
152
|
+
'justify-content': 'center'
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (slot.height && slot.height !== 0) {
|
|
156
|
+
slideStyles.height = '100%';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
Object.entries(slideStyles).forEach(([key, value]) => {
|
|
160
|
+
slideElement.style.setProperty(key, value);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// 광고 렌더링
|
|
164
|
+
const adElement = AdRendererFactory.render(
|
|
165
|
+
ad,
|
|
166
|
+
slot,
|
|
167
|
+
trackEventCallback
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
slideElement.appendChild(adElement);
|
|
171
|
+
slideContainer.appendChild(slideElement);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// 텍스트 광고인지 확인 (모든 광고가 텍스트 타입인 경우)
|
|
175
|
+
const isAllTextAds = advertisements.every(ad => ad.adType === AdType.TEXT);
|
|
176
|
+
|
|
177
|
+
// 무채색 도트 인디케이터 생성 (원본 광고 수만큼) - 텍스트 광고가 아닐 때만
|
|
178
|
+
const dotContainer = isAllTextAds ? null : SliderManager.createMinimalDotIndicator(advertisements.length);
|
|
179
|
+
|
|
180
|
+
// 슬라이더 상태 관리
|
|
181
|
+
let currentSlide = 0;
|
|
182
|
+
const totalSlides = advertisements.length;
|
|
183
|
+
const autoSlideInterval = (options?.autoSlideInterval || 3) * 1000; // 기본 3초
|
|
184
|
+
|
|
185
|
+
// 슬라이드 이동 함수 (무한 루프 지원)
|
|
186
|
+
const moveToSlide = (index: number, instant = false) => {
|
|
187
|
+
currentSlide = index;
|
|
188
|
+
|
|
189
|
+
// 애니메이션 임시 비활성화 (무한 루프용)
|
|
190
|
+
if (instant) {
|
|
191
|
+
slideContainer.style.transition = 'none';
|
|
192
|
+
} else {
|
|
193
|
+
slideContainer.style.transition = 'transform 0.4s ease-out';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 항상 퍼센트 기반으로 이동
|
|
197
|
+
slideContainer.style.transform = `translateX(-${(100 / extendedAds.length) * currentSlide}%)`;
|
|
198
|
+
|
|
199
|
+
// 도트 업데이트 (무채색 스타일) - 실제 광고 인덱스 기준, 텍스트 광고가 아닐 때만
|
|
200
|
+
const actualIndex = currentSlide === totalSlides ? 0 : currentSlide;
|
|
201
|
+
if (dotContainer) {
|
|
202
|
+
const dots = dotContainer.querySelectorAll('.adstage-dot');
|
|
203
|
+
dots.forEach((dot: Element, i: number) => {
|
|
204
|
+
const dotElement = dot as HTMLElement;
|
|
205
|
+
if (i === actualIndex) {
|
|
206
|
+
dotElement.classList.add('active');
|
|
207
|
+
dotElement.style.background = '#666666';
|
|
208
|
+
dotElement.style.borderColor = '#666666';
|
|
209
|
+
dotElement.style.opacity = '1';
|
|
210
|
+
} else {
|
|
211
|
+
dotElement.classList.remove('active');
|
|
212
|
+
dotElement.style.background = 'transparent';
|
|
213
|
+
dotElement.style.borderColor = '#cccccc';
|
|
214
|
+
dotElement.style.opacity = '0.7';
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 현재 슬라이드의 광고에 대해 노출 이벤트 추적
|
|
220
|
+
if (actualIndex > 0) { // 첫 번째는 이미 loadSlot에서 추적됨
|
|
221
|
+
trackEventCallback(advertisements[actualIndex]._id, slot.id, AdEventType.IMPRESSION);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// 무한 루프 처리 함수
|
|
226
|
+
const handleInfiniteLoop = () => {
|
|
227
|
+
if (currentSlide === totalSlides) {
|
|
228
|
+
// 복사된 첫 번째 슬라이드에 도달하면 즉시 원본 첫 번째로 이동
|
|
229
|
+
setTimeout(() => {
|
|
230
|
+
moveToSlide(0, true); // 애니메이션 없이 즉시 이동
|
|
231
|
+
}, 400); // transition 시간과 맞춤
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// 도트 클릭 이벤트 (텍스트 광고가 아닐 때만)
|
|
236
|
+
if (dotContainer) {
|
|
237
|
+
const dots = dotContainer.querySelectorAll('.adstage-dot');
|
|
238
|
+
dots.forEach((dot: Element, index: number) => {
|
|
239
|
+
dot.addEventListener('click', () => moveToSlide(index));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 자동 슬라이드 (한 방향으로만 무한 진행)
|
|
244
|
+
let autoSlideTimer = setInterval(() => {
|
|
245
|
+
const nextIndex = currentSlide + 1;
|
|
246
|
+
moveToSlide(nextIndex);
|
|
247
|
+
handleInfiniteLoop();
|
|
248
|
+
}, autoSlideInterval);
|
|
249
|
+
|
|
250
|
+
// 마우스 호버 시 자동 슬라이드 일시정지
|
|
251
|
+
sliderWrapper.addEventListener('mouseenter', () => {
|
|
252
|
+
clearInterval(autoSlideTimer);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
sliderWrapper.addEventListener('mouseleave', () => {
|
|
256
|
+
autoSlideTimer = setInterval(() => {
|
|
257
|
+
const nextIndex = currentSlide + 1;
|
|
258
|
+
moveToSlide(nextIndex);
|
|
259
|
+
handleInfiniteLoop();
|
|
260
|
+
}, autoSlideInterval);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 터치 제스처 지원 수정 (무한 루프 지원)
|
|
264
|
+
SliderManager.addTouchSupport(slideContainer, moveToSlide, () => currentSlide, totalSlides, handleInfiniteLoop);
|
|
265
|
+
|
|
266
|
+
// 요소들 조립 (화살표 제거, 도트는 텍스트 광고가 아닐 때만 추가)
|
|
267
|
+
sliderWrapper.appendChild(slideContainer);
|
|
268
|
+
if (dotContainer) {
|
|
269
|
+
sliderWrapper.appendChild(dotContainer);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 첫 번째 도트 활성화
|
|
273
|
+
moveToSlide(0);
|
|
274
|
+
|
|
275
|
+
// 사용자가 크기를 지정하지 않은 경우, 첫 번째 슬라이드 크기에 맞춰 래퍼 크기 동적 조정
|
|
276
|
+
if (!slot.width || slot.width === 0) {
|
|
277
|
+
// DOM 렌더링 후 크기 측정
|
|
278
|
+
setTimeout(() => {
|
|
279
|
+
const firstSlide = slideContainer.children[0] as HTMLElement;
|
|
280
|
+
if (firstSlide) {
|
|
281
|
+
const firstAdElement = firstSlide.children[0] as HTMLElement;
|
|
282
|
+
if (firstAdElement) {
|
|
283
|
+
const rect = firstAdElement.getBoundingClientRect();
|
|
284
|
+
sliderWrapper.style.width = `${rect.width}px`;
|
|
285
|
+
if (!slot.height || slot.height === 0) {
|
|
286
|
+
sliderWrapper.style.height = `${rect.height}px`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 크기 조정 후 overflow hidden 재적용
|
|
290
|
+
sliderWrapper.style.overflow = 'hidden';
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}, 10);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return sliderWrapper;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 무채색 미니멀 도트 인디케이터 생성
|
|
301
|
+
*/
|
|
302
|
+
private static createMinimalDotIndicator(count: number): HTMLElement {
|
|
303
|
+
const dotContainer = document.createElement('div');
|
|
304
|
+
dotContainer.className = 'adstage-dots';
|
|
305
|
+
dotContainer.style.cssText = `
|
|
306
|
+
position: absolute;
|
|
307
|
+
bottom: 15px;
|
|
308
|
+
left: 50%;
|
|
309
|
+
transform: translateX(-50%);
|
|
310
|
+
display: flex;
|
|
311
|
+
gap: 12px;
|
|
312
|
+
z-index: 3;
|
|
313
|
+
padding: 8px 16px;
|
|
314
|
+
border-radius: 20px;
|
|
315
|
+
background: rgba(255, 255, 255, 0.1);
|
|
316
|
+
backdrop-filter: blur(10px);
|
|
317
|
+
`;
|
|
318
|
+
|
|
319
|
+
for (let i = 0; i < count; i++) {
|
|
320
|
+
const dot = document.createElement('button');
|
|
321
|
+
dot.className = 'adstage-dot';
|
|
322
|
+
dot.style.cssText = `
|
|
323
|
+
width: 8px;
|
|
324
|
+
height: 8px;
|
|
325
|
+
border-radius: 50%;
|
|
326
|
+
border: 1px solid #cccccc;
|
|
327
|
+
background: transparent;
|
|
328
|
+
cursor: pointer;
|
|
329
|
+
transition: all 0.3s ease;
|
|
330
|
+
outline: none;
|
|
331
|
+
opacity: 0.7;
|
|
332
|
+
padding: 0;
|
|
333
|
+
margin: 0;
|
|
334
|
+
flex-shrink: 0;
|
|
335
|
+
`;
|
|
336
|
+
|
|
337
|
+
// 호버 효과
|
|
338
|
+
dot.addEventListener('mouseenter', () => {
|
|
339
|
+
if (!dot.classList.contains('active')) {
|
|
340
|
+
dot.style.borderColor = '#999999';
|
|
341
|
+
dot.style.opacity = '0.9';
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
dot.addEventListener('mouseleave', () => {
|
|
346
|
+
if (!dot.classList.contains('active')) {
|
|
347
|
+
dot.style.borderColor = '#cccccc';
|
|
348
|
+
dot.style.opacity = '0.7';
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
dotContainer.appendChild(dot);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return dotContainer;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 터치 제스처 지원 추가
|
|
360
|
+
*/
|
|
361
|
+
private static addTouchSupport(
|
|
362
|
+
container: HTMLElement,
|
|
363
|
+
moveToSlide: (index: number, instant?: boolean) => void,
|
|
364
|
+
getCurrentSlide: () => number,
|
|
365
|
+
totalSlides: number,
|
|
366
|
+
handleInfiniteLoop?: () => void
|
|
367
|
+
): void {
|
|
368
|
+
let startX = 0;
|
|
369
|
+
let isDragging = false;
|
|
370
|
+
|
|
371
|
+
container.addEventListener('touchstart', (e) => {
|
|
372
|
+
startX = e.touches[0].clientX;
|
|
373
|
+
isDragging = true;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
container.addEventListener('touchmove', (e) => {
|
|
377
|
+
if (!isDragging) return;
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
container.addEventListener('touchend', (e) => {
|
|
382
|
+
if (!isDragging) return;
|
|
383
|
+
isDragging = false;
|
|
384
|
+
|
|
385
|
+
const endX = e.changedTouches[0].clientX;
|
|
386
|
+
const diff = startX - endX;
|
|
387
|
+
|
|
388
|
+
if (Math.abs(diff) > 50) { // 50px 이상 스와이프 시
|
|
389
|
+
const currentSlide = getCurrentSlide();
|
|
390
|
+
if (diff > 0) {
|
|
391
|
+
// 왼쪽으로 스와이프 (다음 슬라이드)
|
|
392
|
+
const nextIndex = currentSlide + 1;
|
|
393
|
+
moveToSlide(nextIndex);
|
|
394
|
+
if (handleInfiniteLoop) {
|
|
395
|
+
handleInfiniteLoop();
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
// 오른쪽으로 스와이프 (이전 슬라이드)
|
|
399
|
+
const prevIndex = currentSlide > 0 ? currentSlide - 1 : totalSlides - 1;
|
|
400
|
+
moveToSlide(prevIndex);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
interface AdErrorBoundaryProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
fallback?: ReactNode;
|
|
6
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface AdErrorBoundaryState {
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 광고 컴포넌트에서 발생하는 오류를 포착하는 Error Boundary
|
|
16
|
+
* 광고 로딩 실패 시 fallback UI를 표시하고 앱 전체가 크래시되는 것을 방지
|
|
17
|
+
*/
|
|
18
|
+
export class AdErrorBoundary extends Component<AdErrorBoundaryProps, AdErrorBoundaryState> {
|
|
19
|
+
constructor(props: AdErrorBoundaryProps) {
|
|
20
|
+
super(props);
|
|
21
|
+
this.state = { hasError: false, error: null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static getDerivedStateFromError(error: Error): AdErrorBoundaryState {
|
|
25
|
+
return { hasError: true, error };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
29
|
+
console.error('AdStage Error Boundary caught an error:', error, errorInfo);
|
|
30
|
+
|
|
31
|
+
// 사용자 정의 에러 핸들러 호출
|
|
32
|
+
if (this.props.onError) {
|
|
33
|
+
this.props.onError(error, errorInfo);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
render() {
|
|
38
|
+
if (this.state.hasError) {
|
|
39
|
+
// 사용자 정의 fallback이 있으면 사용, 없으면 기본 fallback 표시
|
|
40
|
+
if (this.props.fallback) {
|
|
41
|
+
return this.props.fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
padding: '20px',
|
|
48
|
+
textAlign: 'center',
|
|
49
|
+
backgroundColor: '#fee',
|
|
50
|
+
border: '1px solid #fcc',
|
|
51
|
+
borderRadius: '4px',
|
|
52
|
+
color: '#c00',
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<h3>광고 로딩 오류</h3>
|
|
56
|
+
<p>광고를 불러오는 중 문제가 발생했습니다.</p>
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
59
|
+
style={{
|
|
60
|
+
padding: '8px 16px',
|
|
61
|
+
backgroundColor: '#fff',
|
|
62
|
+
border: '1px solid #ccc',
|
|
63
|
+
borderRadius: '4px',
|
|
64
|
+
cursor: 'pointer',
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
다시 시도
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return this.props.children;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useMemo } from 'react';
|
|
2
|
+
import { AdType } from '../../types/advertisement';
|
|
3
|
+
import { useAdStage } from '../hooks/useAdStage';
|
|
4
|
+
|
|
5
|
+
interface AdSlotProps {
|
|
6
|
+
slotId: string;
|
|
7
|
+
adType: AdType;
|
|
8
|
+
width?: string | number;
|
|
9
|
+
height?: string | number;
|
|
10
|
+
className?: string;
|
|
11
|
+
style?: React.CSSProperties;
|
|
12
|
+
autoSlideInterval?: number;
|
|
13
|
+
sliderEffect?: 'slide' | 'fade';
|
|
14
|
+
language?: string;
|
|
15
|
+
deviceType?: string;
|
|
16
|
+
country?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 범용 광고 슬롯 컴포넌트
|
|
21
|
+
* 모든 광고 타입을 지원하며 SSR 환경에서도 안전하게 동작
|
|
22
|
+
*/
|
|
23
|
+
export const AdSlot: React.FC<AdSlotProps> = ({
|
|
24
|
+
slotId,
|
|
25
|
+
adType,
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
autoSlideInterval = 3,
|
|
31
|
+
sliderEffect = 'slide',
|
|
32
|
+
language,
|
|
33
|
+
deviceType,
|
|
34
|
+
country,
|
|
35
|
+
}) => {
|
|
36
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const { sdk, isLoading, error } = useAdStage();
|
|
38
|
+
|
|
39
|
+
const containerId = useMemo(() => `adstage-${slotId}`, [slotId]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!sdk || !containerRef.current || isLoading || error) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 컨테이너에 ID 설정
|
|
47
|
+
containerRef.current.id = containerId;
|
|
48
|
+
|
|
49
|
+
// 광고 슬롯 생성
|
|
50
|
+
const createSlot = async () => {
|
|
51
|
+
try {
|
|
52
|
+
await sdk.createSlot(slotId, containerId, adType, {
|
|
53
|
+
width,
|
|
54
|
+
height,
|
|
55
|
+
language,
|
|
56
|
+
deviceType,
|
|
57
|
+
country,
|
|
58
|
+
autoSlideInterval,
|
|
59
|
+
sliderEffect,
|
|
60
|
+
});
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(`Failed to create ad slot ${slotId}:`, err);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
createSlot();
|
|
67
|
+
|
|
68
|
+
// 클린업 함수
|
|
69
|
+
return () => {
|
|
70
|
+
// SDK에 removeSlot 메서드가 없으므로 DOM 정리만 수행
|
|
71
|
+
if (containerRef.current) {
|
|
72
|
+
containerRef.current.innerHTML = '';
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}, [
|
|
76
|
+
sdk,
|
|
77
|
+
slotId,
|
|
78
|
+
containerId,
|
|
79
|
+
adType,
|
|
80
|
+
width,
|
|
81
|
+
height,
|
|
82
|
+
language,
|
|
83
|
+
deviceType,
|
|
84
|
+
country,
|
|
85
|
+
autoSlideInterval,
|
|
86
|
+
sliderEffect,
|
|
87
|
+
isLoading,
|
|
88
|
+
error,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const containerStyle: React.CSSProperties = {
|
|
92
|
+
width: typeof width === 'number' ? `${width}px` : width,
|
|
93
|
+
height: typeof height === 'number' ? `${height}px` : height,
|
|
94
|
+
...style,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// 로딩 상태 표시
|
|
98
|
+
if (isLoading) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={className}
|
|
102
|
+
style={{
|
|
103
|
+
...containerStyle,
|
|
104
|
+
display: 'flex',
|
|
105
|
+
alignItems: 'center',
|
|
106
|
+
justifyContent: 'center',
|
|
107
|
+
backgroundColor: '#f5f5f5',
|
|
108
|
+
border: '1px dashed #ccc',
|
|
109
|
+
color: '#999',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
Loading Ad...
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 에러 상태 표시
|
|
118
|
+
if (error) {
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={className}
|
|
122
|
+
style={{
|
|
123
|
+
...containerStyle,
|
|
124
|
+
display: 'flex',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
justifyContent: 'center',
|
|
127
|
+
backgroundColor: '#fee',
|
|
128
|
+
border: '1px solid #fcc',
|
|
129
|
+
color: '#c00',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
Ad Load Error
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
ref={containerRef}
|
|
140
|
+
className={className}
|
|
141
|
+
style={containerStyle}
|
|
142
|
+
/>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { AdType } from '../../types/advertisement';
|
|
3
|
+
import { AdSlot } from './AdSlot';
|
|
4
|
+
|
|
5
|
+
interface BannerAdProps {
|
|
6
|
+
slotId: string;
|
|
7
|
+
width?: string | number;
|
|
8
|
+
height?: string | number;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: React.CSSProperties;
|
|
11
|
+
autoSlideInterval?: number;
|
|
12
|
+
sliderEffect?: 'slide' | 'fade';
|
|
13
|
+
language?: string;
|
|
14
|
+
deviceType?: string;
|
|
15
|
+
country?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 배너 광고 전용 컴포넌트
|
|
20
|
+
* AdSlot의 래퍼로 adType이 BANNER로 고정됨
|
|
21
|
+
*/
|
|
22
|
+
export const BannerAd: React.FC<BannerAdProps> = (props) => {
|
|
23
|
+
return <AdSlot {...props} adType={AdType.BANNER} />;
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { AdType } from '../../types/advertisement';
|
|
3
|
+
import { AdSlot } from './AdSlot';
|
|
4
|
+
|
|
5
|
+
interface InterstitialAdProps {
|
|
6
|
+
slotId: string;
|
|
7
|
+
width?: string | number;
|
|
8
|
+
height?: string | number;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: React.CSSProperties;
|
|
11
|
+
autoSlideInterval?: number;
|
|
12
|
+
sliderEffect?: 'slide' | 'fade';
|
|
13
|
+
language?: string;
|
|
14
|
+
deviceType?: string;
|
|
15
|
+
country?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 인터스티셜 광고 전용 컴포넌트
|
|
20
|
+
* AdSlot의 래퍼로 adType이 INTERSTITIAL로 고정됨
|
|
21
|
+
*/
|
|
22
|
+
export const InterstitialAd: React.FC<InterstitialAdProps> = (props) => {
|
|
23
|
+
return <AdSlot {...props} adType={AdType.INTERSTITIAL} />;
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { AdType } from '../../types/advertisement';
|
|
3
|
+
import { AdSlot } from './AdSlot';
|
|
4
|
+
|
|
5
|
+
interface NativeAdProps {
|
|
6
|
+
slotId: string;
|
|
7
|
+
width?: string | number;
|
|
8
|
+
height?: string | number;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: React.CSSProperties;
|
|
11
|
+
autoSlideInterval?: number;
|
|
12
|
+
sliderEffect?: 'slide' | 'fade';
|
|
13
|
+
language?: string;
|
|
14
|
+
deviceType?: string;
|
|
15
|
+
country?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 네이티브 광고 전용 컴포넌트
|
|
20
|
+
* AdSlot의 래퍼로 adType이 NATIVE로 고정됨
|
|
21
|
+
*/
|
|
22
|
+
export const NativeAd: React.FC<NativeAdProps> = (props) => {
|
|
23
|
+
return <AdSlot {...props} adType={AdType.NATIVE} />;
|
|
24
|
+
};
|