o2o-castad-frontend/src/pages/Landing/HeroSection.tsx

203 lines
7.6 KiB
TypeScript
Executable File

import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
size: number;
initialX: number;
initialY: number;
color: string;
minX: number;
maxX: number;
minY: number;
maxY: number;
}
const orbConfigs: OrbConfig[] = [
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
const { t } = useTranslation();
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
useEffect(() => {
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
const timer = setTimeout(() => {
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
}, 800);
return () => clearTimeout(timer);
}
}, []);
// Orb 랜덤 이동 애니메이션
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
let targetX = currentX;
let targetY = currentY;
let scale = 1;
let targetScale = 1;
let isFirstMove = true;
const generateNewTarget = () => {
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
} else {
targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY;
}
targetScale = 0.9 + Math.random() * 0.2;
};
const animate = () => {
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`;
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
if (distance < 1) generateNewTarget();
animationRefs.current[index] = requestAnimationFrame(animate);
};
generateNewTarget();
animate();
};
orbRefs.current.forEach((orb, index) => {
if (orb) moveOrb(orb, index);
});
return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id));
};
}, []);
return (
<div className="hero-section">
{/* Animated background orbs */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
>
{orbConfigs.map((config, index) => (
<div
key={config.id}
ref={el => { orbRefs.current[index] = el; }}
className="hero-orb-random"
style={{
width: `${config.size}px`,
height: `${config.size}px`,
background: config.color,
left: `${config.initialX}%`,
top: `${config.initialY}%`,
}}
/>
))}
</div>
<div className="hero-content">
<img
src="/assets/images/ADO2_with Slogan_white.svg"
alt="ADO2"
className="hero-logo"
/>
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
} else {
setIsLoginPromptOpen(true);
}
}}
error={externalError || null}
/>
</div>
{/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10l5 5 5-5" />
</svg>
</div>
</button>
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
onNext={tutorial.nextHint}
onPrev={tutorial.prevHint}
onSkip={tutorial.skipTutorial}
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address, category) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address, category);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div>
);
};
export default HeroSection;