235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, ChevronRight, Check } from 'lucide-react';
|
|
|
|
interface TourStep {
|
|
target: string;
|
|
title: string;
|
|
description: string;
|
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
}
|
|
|
|
interface OnboardingTourProps {
|
|
steps: TourStep[];
|
|
onComplete: () => void;
|
|
onSkip: () => void;
|
|
}
|
|
|
|
const OnboardingTour: React.FC<OnboardingTourProps> = ({ steps, onComplete, onSkip }) => {
|
|
const [currentStep, setCurrentStep] = useState(0);
|
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
|
|
useEffect(() => {
|
|
// Remove previous highlights
|
|
document.querySelectorAll('.tour-highlight').forEach(el => {
|
|
el.classList.remove('tour-highlight');
|
|
});
|
|
|
|
const updatePosition = () => {
|
|
const target = document.querySelector(steps[currentStep].target);
|
|
|
|
if (!target) {
|
|
console.warn(`Tour target not found: ${steps[currentStep].target}`);
|
|
// Try again after a short delay
|
|
setTimeout(updatePosition, 100);
|
|
return;
|
|
}
|
|
|
|
// 팝업을 화면 기준으로 고정 배치 (스크롤에 영향받지 않음)
|
|
const viewportWidth = window.innerWidth;
|
|
|
|
// 모바일/데스크톱 구분
|
|
const isMobile = viewportWidth < 768;
|
|
|
|
let top: number;
|
|
let left: number;
|
|
|
|
if (isMobile) {
|
|
// 모바일: 화면 하단 고정
|
|
top = 20; // viewport 기준
|
|
left = 20;
|
|
} else {
|
|
// 데스크톱: 화면 우측 상단 고정
|
|
top = 20; // viewport 기준 (상단에서 20px)
|
|
left = viewportWidth - 340; // 우측에서 340px (카드 너비 320 + 여유 20)
|
|
}
|
|
|
|
setPosition({ top, left });
|
|
|
|
// Highlight target element
|
|
target.classList.add('tour-highlight');
|
|
|
|
// Scroll into view
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
};
|
|
|
|
// Wait for DOM to be ready
|
|
const timer = setTimeout(updatePosition, 150);
|
|
|
|
window.addEventListener('resize', updatePosition);
|
|
window.addEventListener('scroll', updatePosition);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
window.removeEventListener('resize', updatePosition);
|
|
window.removeEventListener('scroll', updatePosition);
|
|
};
|
|
}, [currentStep, steps]);
|
|
|
|
const handleNext = () => {
|
|
if (currentStep < steps.length - 1) {
|
|
setCurrentStep(currentStep + 1);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
};
|
|
|
|
const handlePrev = () => {
|
|
if (currentStep > 0) {
|
|
setCurrentStep(currentStep - 1);
|
|
}
|
|
};
|
|
|
|
const currentStepData = steps[currentStep];
|
|
|
|
return (
|
|
<>
|
|
{/* Overlay - 매우 투명하게 */}
|
|
<div className="fixed inset-0 bg-black/5 z-40 animate-in fade-in" />
|
|
|
|
{/* Tour Card - viewport 기준 고정 위치 */}
|
|
<div
|
|
className="fixed z-50 w-80 bg-gray-900/98 backdrop-blur-md border-2 border-purple-500 rounded-2xl shadow-2xl animate-in fade-in zoom-in-95"
|
|
style={{
|
|
top: `${position.top}px`, // viewport 기준
|
|
right: '20px', // 우측 고정
|
|
maxHeight: 'calc(100vh - 40px)', // 화면 높이에서 여유 40px
|
|
overflowY: 'auto'
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1">
|
|
{steps.map((_, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={`h-2 rounded-full transition-all ${
|
|
idx === currentStep
|
|
? 'w-8 bg-purple-500'
|
|
: idx < currentStep
|
|
? 'w-2 bg-green-500'
|
|
: 'w-2 bg-gray-600'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-gray-400 ml-2">
|
|
{currentStep + 1} / {steps.length}
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={onSkip}
|
|
className="text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-5">
|
|
<h3 className="text-xl font-bold text-white mb-2 flex items-center gap-2">
|
|
<span className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-sm">
|
|
{currentStep + 1}
|
|
</span>
|
|
{currentStepData.title}
|
|
</h3>
|
|
<p className="text-gray-300 text-sm leading-relaxed">
|
|
{currentStepData.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-gray-700 flex items-center justify-between">
|
|
<button
|
|
onClick={onSkip}
|
|
className="text-sm text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
건너뛰기
|
|
</button>
|
|
<div className="flex gap-2">
|
|
{currentStep > 0 && (
|
|
<button
|
|
onClick={handlePrev}
|
|
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm font-semibold transition-all"
|
|
>
|
|
이전
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={handleNext}
|
|
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm font-semibold transition-all flex items-center gap-2"
|
|
>
|
|
{currentStep === steps.length - 1 ? (
|
|
<>
|
|
<Check className="w-4 h-4" />
|
|
완료
|
|
</>
|
|
) : (
|
|
<>
|
|
다음
|
|
<ChevronRight className="w-4 h-4" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CSS for highlight effect */}
|
|
<style>{`
|
|
.tour-highlight {
|
|
position: relative !important;
|
|
z-index: 9999 !important;
|
|
background-color: white !important;
|
|
box-shadow:
|
|
0 0 0 6px rgba(168, 85, 247, 1),
|
|
0 0 0 12px rgba(168, 85, 247, 0.5),
|
|
0 0 40px 0 rgba(168, 85, 247, 0.6),
|
|
0 0 80px 0 rgba(168, 85, 247, 0.3) !important;
|
|
border-radius: 16px !important;
|
|
animation: pulse-highlight 2s cubic-bezier(0.4, 0, 0.6, 1) infinite !important;
|
|
outline: none !important;
|
|
transform: scale(1.02) !important;
|
|
transition: all 0.3s ease !important;
|
|
}
|
|
|
|
.tour-highlight * {
|
|
position: relative !important;
|
|
z-index: 10000 !important;
|
|
}
|
|
|
|
@keyframes pulse-highlight {
|
|
0%, 100% {
|
|
box-shadow:
|
|
0 0 0 6px rgba(168, 85, 247, 1),
|
|
0 0 0 12px rgba(168, 85, 247, 0.5),
|
|
0 0 40px 0 rgba(168, 85, 247, 0.6),
|
|
0 0 80px 0 rgba(168, 85, 247, 0.3);
|
|
transform: scale(1.02);
|
|
}
|
|
50% {
|
|
box-shadow:
|
|
0 0 0 8px rgba(168, 85, 247, 1),
|
|
0 0 0 16px rgba(168, 85, 247, 0.6),
|
|
0 0 60px 0 rgba(168, 85, 247, 0.8),
|
|
0 0 100px 0 rgba(168, 85, 247, 0.4);
|
|
transform: scale(1.03);
|
|
}
|
|
}
|
|
`}</style>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default OnboardingTour;
|