544 lines
20 KiB
TypeScript
Executable File
544 lines
20 KiB
TypeScript
Executable File
|
|
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<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
|
const { t, i18n } = useTranslation();
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [searchType, setSearchType] = useState<SearchType>('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<AccommodationSearchItem[]>([]);
|
|
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
|
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
const animationRefs = useRef<number[]>([]);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const autocompleteRef = useRef<HTMLDivElement>(null);
|
|
const debounceRef = useRef<NodeJS.Timeout | null>(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<HTMLInputElement>) => {
|
|
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
|
|
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 (
|
|
<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">
|
|
{/* Logo Image */}
|
|
<img
|
|
src="/assets/images/ado2-logo.svg"
|
|
alt="ADO2"
|
|
className="hero-logo"
|
|
/>
|
|
|
|
|
|
{/* Input Form */}
|
|
<div className="hero-form">
|
|
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
|
|
{/* 드롭다운 */}
|
|
<div className="hero-dropdown-container" ref={dropdownRef}>
|
|
<button
|
|
type="button"
|
|
className="hero-dropdown-trigger"
|
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
|
>
|
|
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
|
|
<svg
|
|
width="12"
|
|
height="12"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
|
|
>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
</button>
|
|
{isDropdownOpen && (
|
|
<div className="hero-dropdown-menu">
|
|
{searchTypeOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setSearchType(option.value);
|
|
setIsDropdownOpen(false);
|
|
if (option.value === 'url' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_URL)) {
|
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_URL, undefined, true), 300);
|
|
} else if (option.value === 'name' && !tutorial.hasSeen(TUTORIAL_KEYS.LANDING_NAME)) {
|
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.LANDING_NAME, undefined, true), 300);
|
|
}
|
|
}}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="hero-input-container" ref={autocompleteRef}>
|
|
<input
|
|
type="text"
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
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<HTMLInputElement>) => {
|
|
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' && (
|
|
<div className="hero-autocomplete-dropdown">
|
|
{isAutocompleteLoading ? (
|
|
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
|
|
) : (
|
|
autocompleteResults.map((item, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
handleSelectAutocomplete(item);
|
|
}}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
>
|
|
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
|
|
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{inputValue && (
|
|
<button
|
|
type="button"
|
|
className="hero-input-clear"
|
|
onClick={() => {
|
|
setInputValue('');
|
|
setLocalError('');
|
|
setAutocompleteResults([]);
|
|
setShowAutocomplete(false);
|
|
}}
|
|
>
|
|
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<span className="hero-input-hint">{getGuideText()}</span>
|
|
{error && (
|
|
<p className="hero-error">{error}</p>
|
|
)}
|
|
|
|
<button onClick={handleStart} className="hero-button">
|
|
{t('landing.hero.analyzeButton')}
|
|
</button>
|
|
</div>
|
|
</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>
|
|
|
|
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
|
{isTestPage && onTestData && (
|
|
<button
|
|
onClick={handleTestData}
|
|
disabled={isLoadingTest}
|
|
className="test-data-button"
|
|
>
|
|
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
|
</button>
|
|
)}
|
|
|
|
{tutorial.isActive && (
|
|
<TutorialOverlay
|
|
hints={tutorial.hints}
|
|
currentIndex={tutorial.currentHintIndex}
|
|
onNext={tutorial.nextHint}
|
|
onPrev={tutorial.prevHint}
|
|
onSkip={tutorial.skipTutorial}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HeroSection;
|