CASTAD-v0.1/components/OnboardingTour.tsx

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;