203 lines
7.6 KiB
TypeScript
Executable File
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;
|