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

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;