import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api'; import { CrawlingResponse } from '../../types/api'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; type SearchType = 'url' | 'name'; // 환경변수에서 테스트 모드 확인 const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; interface HeroSectionProps { onAnalyze?: (value: string, type?: SearchType) => void; onAutocomplete?: (data: AutocompleteRequest) => void; onTestData?: (data: CrawlingResponse) => void; onNext?: () => void; error?: string | null; scrollProgress?: number; // 0 ~ 1 (스크롤 진행률) } const isValidUrl = (string: string): boolean => { try { const url = new URL(string); return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } }; // Orb configuration with movement zones to prevent overlap interface OrbConfig { id: string; size: number; initialX: number; initialY: number; color: string; // Movement bounds for each orb minX: number; maxX: number; minY: number; maxY: number; } // 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement const orbConfigs: OrbConfig[] = [ // Top-left zone { 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 }, // Top-right zone { 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 }, // Middle-left zone { 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 }, // Middle-right zone { 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 }, // Bottom-left zone { 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 }, // Bottom-right zone { 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 }, ]; const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const { t, i18n } = useTranslation(); const [inputValue, setInputValue] = useState(''); const [searchType, setSearchType] = useState('url'); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [localError, setLocalError] = useState(''); const [isLoadingTest, setIsLoadingTest] = useState(false); // 테스트 데이터 로드 핸들러 const handleTestData = async () => { if (!onTestData) return; setIsLoadingTest(true); try { const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json'; const response = await fetch(jsonFile); const data: CrawlingResponse = await response.json(); onTestData(data); } catch (error) { console.error('테스트 데이터 로드 실패:', error); setLocalError(t('landing.hero.testDataLoadFailed')); } finally { setIsLoadingTest(false); } }; const [isFocused, setIsFocused] = useState(false); const [autocompleteResults, setAutocompleteResults] = useState([]); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const [highlightedIndex, setHighlightedIndex] = useState(-1); const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const animationRefs = useRef([]); const dropdownRef = useRef(null); const autocompleteRef = useRef(null); const debounceRef = useRef(null); const tutorial = useTutorial(); // 첫 방문 시 랜딩 튜토리얼 시작 (URL/업체명 분기 튜토리얼도 아직 안 본 경우) useEffect(() => { const neitherBranchSeen = !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL) && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME); if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING) && neitherBranchSeen) { const timer = setTimeout(() => { tutorial.startTutorial(TUTORIAL_KEYS.LANDING); }, 800); return () => clearTimeout(timer); } }, []); const searchTypeOptions = [ { value: 'url' as SearchType, label: 'URL' }, { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') }, ]; // 드롭다운 외부 클릭 감지 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsDropdownOpen(false); } if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) { setShowAutocomplete(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); // 업체명 검색 시 자동완성 (디바운스 적용) const handleAutocompleteSearch = useCallback(async (query: string) => { if (!query.trim() || searchType !== 'name') { setAutocompleteResults([]); setShowAutocomplete(false); return; } setIsAutocompleteLoading(true); try { const response = await searchAccommodation(query); setAutocompleteResults(response.items || []); setShowAutocomplete(response.items && response.items.length > 0); } catch (error) { console.error('자동완성 검색 오류:', error); setAutocompleteResults([]); setShowAutocomplete(false); } finally { setIsAutocompleteLoading(false); } }, [searchType]); // 자동완성 항목 선택 - 업체 정보 저장 const handleSelectAutocomplete = (item: AccommodationSearchItem) => { setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거 setSelectedItem(item); // 선택된 업체 정보 저장 setShowAutocomplete(false); setAutocompleteResults([]); setHighlightedIndex(-1); // LANDING_NAME 튜토리얼 진행 중이면 다음 힌트로 이동 if (tutorial.isActive && tutorial.tutorialKey === TUTORIAL_KEYS.LANDING_NAME) { tutorial.nextHint(); } }; // 키보드 네비게이션 핸들러 const handleKeyDown = (e: React.KeyboardEvent) => { // 자동완성이 표시될 때만 키보드 네비게이션 활성화 if (showAutocomplete && autocompleteResults.length > 0) { switch (e.key) { case 'ArrowDown': e.preventDefault(); setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0 ); return; case 'ArrowUp': e.preventDefault(); setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1 ); return; case 'Enter': e.preventDefault(); // 항상 Enter 기본 동작 방지 if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) { handleSelectAutocomplete(autocompleteResults[highlightedIndex]); } return; case 'Escape': e.preventDefault(); setShowAutocomplete(false); setHighlightedIndex(-1); return; } } // 업체명 검색 모드에서 Enter 키 입력 시 기본 동작 방지 (폼 제출 방지) if (e.key === 'Enter' && searchType === 'name') { e.preventDefault(); } }; // Random movement for orbs 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 = () => { // Move within the orb's designated zone 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; // 0.9 to 1.1 (더 작은 범위) }; const animate = () => { // Slow speed - 일정한 속도로 부드럽게 const speed = 0.003; currentX += (targetX - currentX) * speed; currentY += (targetY - currentY) * speed; scale += (targetScale - scale) * speed; // Apply transform orb.style.left = `${currentX}%`; orb.style.top = `${currentY}%`; orb.style.transform = `scale(${scale})`; // Generate new target when close enough 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); } }); // Cleanup return () => { animationRefs.current.forEach(id => cancelAnimationFrame(id)); }; }, []); const error = externalError || localError; const getPlaceholder = () => { return searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName'); }; const getGuideText = () => { return searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName'); }; const handleStart = () => { if (!inputValue.trim()) { setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired')); return; } if (searchType === 'url' && !isValidUrl(inputValue.trim())) { setLocalError(t('landing.hero.errorInvalidUrl')); return; } setLocalError(''); if (searchType === 'name' && selectedItem && onAutocomplete) { // 업체명 검색인 경우 autocomplete API 호출 const request: AutocompleteRequest = { address: selectedItem.address, roadAddress: selectedItem.roadAddress, title: selectedItem.title, }; onAutocomplete(request); } else if (onAnalyze) { // URL 검색인 경우 기존 로직 onAnalyze(inputValue, searchType); } }; return (
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
{orbConfigs.map((config, index) => (
{ 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}%`, }} /> ))}
{/* Logo Image */} ADO2 {/* Input Form */}
{/* 드롭다운 */}
{isDropdownOpen && (
{searchTypeOptions.map((option) => ( ))}
)}
{ let value = e.target.value; // URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...") if (searchType === 'url') { const urlMatch = value.match(/https?:\/\/\S+/); if (urlMatch && urlMatch[0] !== value) { value = urlMatch[0]; } } setInputValue(value); setHighlightedIndex(-1); // 입력 시 하이라이트 초기화 if (localError) setLocalError(''); // 업체명 검색일 때 자동완성 검색 (디바운스) if (searchType === 'name') { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { handleAutocompleteSearch(value); }, 300); } }} onPaste={(e: React.ClipboardEvent) => { if (searchType !== 'url') return; const pasted = e.clipboardData.getData('text'); const urlMatch = pasted.match(/https?:\/\/[^\s]+/); if (urlMatch) { e.preventDefault(); setInputValue(urlMatch[0]); if (localError) setLocalError(''); } }} onFocus={() => { setIsFocused(true); if (searchType === 'name' && autocompleteResults.length > 0) { setShowAutocomplete(true); } }} onBlur={() => setIsFocused(false)} onKeyDown={handleKeyDown} placeholder={getPlaceholder()} className={`hero-input ${inputValue ? 'has-value' : ''}`} /> {/* 자동완성 결과 */} {showAutocomplete && searchType === 'name' && (
{isAutocompleteLoading ? (
{t('landing.hero.searching')}
) : ( autocompleteResults.map((item, index) => ( )) )}
)}
{inputValue && ( )}
{getGuideText()} {error && (

{error}

)}
{/* Footer Indicator */} {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */} {isTestPage && onTestData && ( )} {tutorial.isActive && ( )}
); }; export default HeroSection;