UNPKG

@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
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); } } }); } }