@adstage/web-sdk
Version:
AdStage Web SDK - Production-ready marketing platform SDK with React Provider support for seamless integration
484 lines (421 loc) • 16.8 kB
text/typescript
import { AdType, AdEventType } from '../../types/advertisement';
import type { AdSlot, Advertisement } from '../../types/advertisement';
import { SliderEventTracker } from './slider-event-tracker';
import { AdClickHandler } from '../../utils/ad-click-handler';
/**
* 캐러셀 슬라이더 관리 클래스
* - 배너/비디오 광고용 가로 슬라이드 (횡 스크롤)
* - 무한 루프 캐러셀 지원
* - 터치 제스처 및 자동 슬라이드 기능
* - 도트 인디케이터 포함
*/
export class CarouselSliderManager {
/**
* 간단한 광고 요소 생성 (크기 측정용)
*/
private static createSimpleAdElement(slot: AdSlot, advertisement: Advertisement): HTMLElement {
const adElement = document.createElement('div');
adElement.className = `adstage-ad adstage-${String(slot.adType).toLowerCase()}`;
adElement.setAttribute('data-adstage-ad-id', advertisement._id);
adElement.setAttribute('data-adstage-slot-id', slot.id);
// 기본 스타일 설정
adElement.style.display = 'block';
adElement.style.width = '100%';
adElement.style.height = 'auto';
// 광고 타입별 기본 컨테이너 설정
switch (slot.adType) {
case AdType.BANNER:
if (advertisement.imageUrl) {
const img = document.createElement('img');
img.src = advertisement.imageUrl;
img.style.width = '100%';
img.style.height = 'auto';
img.style.objectFit = 'cover';
adElement.appendChild(img);
} else {
adElement.style.height = '100px';
adElement.style.backgroundColor = '#f0f0f0';
adElement.style.border = '1px dashed #ccc';
adElement.textContent = 'Banner Ad';
}
break;
case AdType.VIDEO:
if (advertisement.videoUrl) {
const video = document.createElement('video');
video.src = advertisement.videoUrl;
video.style.width = '100%';
video.style.height = 'auto';
adElement.appendChild(video);
} else {
adElement.style.height = '200px';
adElement.style.backgroundColor = '#000';
adElement.style.border = '1px solid #666';
adElement.textContent = 'Video Ad';
adElement.style.color = 'white';
}
break;
case AdType.TEXT:
if (advertisement.textContent) {
const textDiv = document.createElement('div');
textDiv.textContent = advertisement.textContent || '';
textDiv.style.padding = '8px';
textDiv.style.fontSize = '14px';
adElement.appendChild(textDiv);
} else {
adElement.style.height = '50px';
adElement.style.padding = '8px';
adElement.textContent = 'Text Ad';
}
break;
default:
adElement.style.height = '100px';
adElement.style.border = '1px dashed #ccc';
adElement.style.backgroundColor = '#f9f9f9';
adElement.textContent = `${slot.adType} Ad`;
}
return adElement;
}
/**
* Create carousel slider container with dot indicators and navigation
*/
static createSliderContainer(
slot: AdSlot,
advertisements: any[],
options: any,
trackEventCallback: (adId: string, slotId: string, eventType: AdEventType) => void,
debug: boolean = false
): HTMLElement {
const sliderWrapper = document.createElement('div');
sliderWrapper.className = 'adstage-slider-wrapper';
// 사용자 지정 크기가 있으면 적용, 없으면 콘텐츠 크기에 맞춤
const containerStyles: Record<string, string> = {
position: 'relative',
overflow: 'hidden',
};
// 사용자가 크기를 지정한 경우
if (slot.width && slot.width !== 0) {
let width: string;
if (typeof slot.width === 'string') {
// 문자열인 경우 px 단위가 있는지 확인
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
} else {
// 숫자인 경우 px 단위 추가
width = `${slot.width}px`;
}
containerStyles.width = width;
containerStyles.display = 'inline-block'; // 지정된 크기에 맞춤 (좌측 정렬)
} else {
// 컨텐츠 크기에 맞춤
containerStyles.display = 'inline-block';
}
if (slot.height && slot.height !== 0) {
const height = typeof slot.height === 'string' ? slot.height : `${slot.height}px`;
containerStyles.height = height;
}
// 스타일 적용
Object.entries(containerStyles).forEach(([key, value]) => {
sliderWrapper.style.setProperty(key, value);
});
// 크기 측정 (width나 height가 설정되지 않은 경우)
const needsWidthMeasurement = !slot.width || slot.width === 0;
const needsHeightMeasurement = !slot.height || slot.height === 0;
if (needsWidthMeasurement || needsHeightMeasurement) {
const measureContainer = document.createElement('div');
measureContainer.style.cssText = `
position: absolute;
visibility: hidden;
white-space: nowrap;
top: -9999px;
left: -9999px;
`;
// width가 설정되어 있으면 측정 컨테이너에도 적용
if (!needsWidthMeasurement && slot.width) {
let width: string;
if (typeof slot.width === 'string') {
width = slot.width.includes('px') || slot.width.includes('%') ? slot.width : `${slot.width}px`;
} else {
width = `${slot.width}px`;
}
measureContainer.style.width = width;
measureContainer.style.whiteSpace = 'normal'; // width가 있으면 줄바꿈 허용
}
document.body.appendChild(measureContainer);
let maxWidth = 0;
let maxHeight = 0;
// 모든 광고의 크기를 측정하여 최대 크기 찾기
advertisements.forEach(ad => {
const measureAdElement = this.createSimpleAdElement(slot, ad);
measureContainer.appendChild(measureAdElement);
const rect = measureAdElement.getBoundingClientRect();
if (rect.width > maxWidth) maxWidth = rect.width;
if (rect.height > maxHeight) maxHeight = rect.height;
// 측정 후 요소 제거
measureContainer.removeChild(measureAdElement);
});
// 측정된 최대 크기로 래퍼 크기 설정
if (needsWidthMeasurement && maxWidth > 0) {
sliderWrapper.style.width = `${maxWidth}px`;
containerStyles.width = `${maxWidth}px`;
}
if (needsHeightMeasurement && maxHeight > 0) {
sliderWrapper.style.height = `${maxHeight}px`;
containerStyles.height = `${maxHeight}px`;
}
// 측정 컨테이너 제거
document.body.removeChild(measureContainer);
}
// 무한 루프를 위해 첫 번째 슬라이드를 마지막에 복사
const extendedAds = [...advertisements, advertisements[0]];
// 슬라이드 컨테이너
const slideContainer = document.createElement('div');
slideContainer.className = 'adstage-slide-container';
// 슬라이드 컨테이너 스타일 - 항상 기본 설정 적용
const slideContainerStyles: Record<string, string> = {
display: 'flex',
transition: 'transform 0.4s ease-out',
width: `${extendedAds.length * 100}%`,
};
if (slot.height && slot.height !== 0) {
slideContainerStyles.height = '100%';
}
Object.entries(slideContainerStyles).forEach(([key, value]) => {
slideContainer.style.setProperty(key, value);
});
// 각 광고를 슬라이드로 생성 (복사된 첫 번째 포함)
extendedAds.forEach((ad, index) => {
const slideElement = document.createElement('div');
slideElement.className = 'adstage-slide';
// 슬라이드 스타일 설정 - 항상 균등 분할
const slideStyles: Record<string, string> = {
width: `${100 / extendedAds.length}%`,
'flex-shrink': '0',
display: 'flex',
'align-items': 'center',
'justify-content': 'center'
};
if (slot.height && slot.height !== 0) {
slideStyles.height = '100%';
}
Object.entries(slideStyles).forEach(([key, value]) => {
slideElement.style.setProperty(key, value);
});
// 광고 렌더링
const adElement = this.createSimpleAdElement(slot, ad);
// 클릭 이벤트 추가 (공통 컴포넌트 사용)
AdClickHandler.addClickEventForSlider(
adElement,
ad,
slot,
trackEventCallback,
debug,
String(slot.adType).toLowerCase()
);
slideElement.appendChild(adElement);
slideContainer.appendChild(slideElement);
});
// 텍스트 광고인지 확인 (모든 광고가 텍스트 타입인 경우)
const isAllTextAds = advertisements.every(ad => ad.adType === AdType.TEXT);
// 무채색 도트 인디케이터 생성 (원본 광고 수만큼) - 텍스트 광고가 아닐 때만
const dotContainer = isAllTextAds ? null : this.createMinimalDotIndicator(advertisements.length);
// 슬라이더 상태 관리
let currentSlide = 0;
const totalSlides = advertisements.length;
const autoSlideInterval = (options?.autoSlideInterval || 3) * 1000; // 기본 3초
// 슬라이드 이동 함수 (무한 루프 지원)
const moveToSlide = (index: number, instant = false) => {
currentSlide = index;
// 애니메이션 임시 비활성화 (무한 루프용)
if (instant) {
slideContainer.style.transition = 'none';
} else {
slideContainer.style.transition = 'transform 0.4s ease-out';
}
// 항상 퍼센트 기반으로 이동
slideContainer.style.transform = `translateX(-${(100 / extendedAds.length) * currentSlide}%)`;
// 도트 업데이트 (무채색 스타일) - 실제 광고 인덱스 기준, 텍스트 광고가 아닐 때만
const actualIndex = currentSlide === totalSlides ? 0 : currentSlide;
if (dotContainer) {
const dots = dotContainer.querySelectorAll('.adstage-dot');
dots.forEach((dot: Element, i: number) => {
const dotElement = dot as HTMLElement;
if (i === actualIndex) {
dotElement.classList.add('active');
dotElement.style.background = '#666666';
dotElement.style.borderColor = '#666666';
dotElement.style.opacity = '1';
} else {
dotElement.classList.remove('active');
dotElement.style.background = 'transparent';
dotElement.style.borderColor = '#cccccc';
dotElement.style.opacity = '0.7';
}
});
}
// 🎯 공통 슬라이더 이벤트 추적 적용 (모든 슬라이드 포함)
SliderEventTracker.trackSlideViewable(
advertisements[actualIndex],
slot,
actualIndex,
trackEventCallback,
debug // debug 모드
);
};
// 무한 루프 처리 함수
const handleInfiniteLoop = () => {
if (currentSlide === totalSlides) {
// 복사된 첫 번째 슬라이드에 도달하면 즉시 원본 첫 번째로 이동
setTimeout(() => {
moveToSlide(0, true); // 애니메이션 없이 즉시 이동
}, 400); // transition 시간과 맞춤
}
};
// 도트 클릭 이벤트 (텍스트 광고가 아닐 때만)
if (dotContainer) {
const dots = dotContainer.querySelectorAll('.adstage-dot');
dots.forEach((dot: Element, index: number) => {
dot.addEventListener('click', () => moveToSlide(index));
});
}
// 자동 슬라이드 (한 방향으로만 무한 진행)
let autoSlideTimer = setInterval(() => {
const nextIndex = currentSlide + 1;
moveToSlide(nextIndex);
handleInfiniteLoop();
}, autoSlideInterval);
// 마우스 호버 시 자동 슬라이드 일시정지
sliderWrapper.addEventListener('mouseenter', () => {
clearInterval(autoSlideTimer);
});
sliderWrapper.addEventListener('mouseleave', () => {
autoSlideTimer = setInterval(() => {
const nextIndex = currentSlide + 1;
moveToSlide(nextIndex);
handleInfiniteLoop();
}, autoSlideInterval);
});
// 터치 제스처 지원 수정 (무한 루프 지원)
this.addTouchSupport(slideContainer, moveToSlide, () => currentSlide, totalSlides, handleInfiniteLoop);
// 요소들 조립 (화살표 제거, 도트는 텍스트 광고가 아닐 때만 추가)
sliderWrapper.appendChild(slideContainer);
if (dotContainer) {
sliderWrapper.appendChild(dotContainer);
}
// 첫 번째 도트 활성화 (moveToSlide에서 자동으로 VIEWABLE 이벤트 발생)
moveToSlide(0);
// 사용자가 크기를 지정하지 않은 경우, 첫 번째 슬라이드 크기에 맞춰 래퍼 크기 동적 조정
if (!slot.width || slot.width === 0) {
// DOM 렌더링 후 크기 측정
setTimeout(() => {
const firstSlide = slideContainer.children[0] as HTMLElement;
if (firstSlide) {
const firstAdElement = firstSlide.children[0] as HTMLElement;
if (firstAdElement) {
const rect = firstAdElement.getBoundingClientRect();
sliderWrapper.style.width = `${rect.width}px`;
if (!slot.height || slot.height === 0) {
sliderWrapper.style.height = `${rect.height}px`;
}
// 크기 조정 후 overflow hidden 재적용
sliderWrapper.style.overflow = 'hidden';
}
}
}, 10);
}
return sliderWrapper;
}
/**
* 무채색 미니멀 도트 인디케이터 생성
*/
private static createMinimalDotIndicator(count: number): HTMLElement {
const dotContainer = document.createElement('div');
dotContainer.className = 'adstage-dots';
dotContainer.style.cssText = `
position: absolute;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12px;
z-index: 3;
padding: 8px 16px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
`;
for (let i = 0; i < count; i++) {
const dot = document.createElement('button');
dot.className = 'adstage-dot';
dot.style.cssText = `
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid #cccccc;
background: transparent;
cursor: pointer;
transition: all 0.3s ease;
outline: none;
opacity: 0.7;
padding: 0;
margin: 0;
flex-shrink: 0;
`;
// 호버 효과
dot.addEventListener('mouseenter', () => {
if (!dot.classList.contains('active')) {
dot.style.borderColor = '#999999';
dot.style.opacity = '0.9';
}
});
dot.addEventListener('mouseleave', () => {
if (!dot.classList.contains('active')) {
dot.style.borderColor = '#cccccc';
dot.style.opacity = '0.7';
}
});
dotContainer.appendChild(dot);
}
return dotContainer;
}
/**
* 터치 제스처 지원 추가
*/
private static addTouchSupport(
container: HTMLElement,
moveToSlide: (index: number, instant?: boolean) => void,
getCurrentSlide: () => number,
totalSlides: number,
handleInfiniteLoop?: () => void
): void {
let startX = 0;
let isDragging = false;
container.addEventListener('touchstart', (e) => {
startX = e.touches[0].clientX;
isDragging = true;
});
container.addEventListener('touchmove', (e) => {
if (!isDragging) return;
e.preventDefault();
});
container.addEventListener('touchend', (e) => {
if (!isDragging) return;
isDragging = false;
const endX = e.changedTouches[0].clientX;
const diff = startX - endX;
if (Math.abs(diff) > 50) { // 50px 이상 스와이프 시
const currentSlide = getCurrentSlide();
if (diff > 0) {
// 왼쪽으로 스와이프 (다음 슬라이드)
const nextIndex = currentSlide + 1;
moveToSlide(nextIndex);
if (handleInfiniteLoop) {
handleInfiniteLoop();
}
} else {
// 오른쪽으로 스와이프 (이전 슬라이드)
const prevIndex = currentSlide > 0 ? currentSlide - 1 : totalSlides - 1;
moveToSlide(prevIndex);
}
}
});
}
}