diff --git a/index.css b/index.css index 3d0e089..c28b10d 100644 --- a/index.css +++ b/index.css @@ -4023,11 +4023,11 @@ } .url-input-logo { - margin-bottom: 40px; + margin-bottom: 0px; } .url-input-logo img { - height: 48px; + height: 70px; width: auto; } diff --git a/public/assets/images/ADO2_with Slogan_white.svg b/public/assets/images/ADO2_with Slogan_white.svg new file mode 100644 index 0000000..e766e16 --- /dev/null +++ b/public/assets/images/ADO2_with Slogan_white.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index f9e9046..2a5ad82 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,8 +14,7 @@ import SocialConnectError from './pages/Social/SocialConnectError'; import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback'; import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import VideoDetailPage from './components/VideoDetailPage'; -import LoginPromptModal from './components/LoginPromptModal'; -import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; +import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; @@ -101,8 +100,26 @@ const App: React.FC = () => { ); const [error, setError] = useState(null); const [isAnalysisComplete, setIsAnalysisComplete] = useState(false); + const [afterLoadTarget, setAfterLoadTarget] = useState('analysis'); const [scrollProgress, setScrollProgress] = useState(0); const [isProcessingCallback, setIsProcessingCallback] = useState(false); + const tutorialVideoCheckedRef = useRef(false); + + // generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off + useEffect(() => { + if (viewMode !== 'generation_flow') return; + if (tutorialVideoCheckedRef.current) return; + tutorialVideoCheckedRef.current = true; + const ENABLED_KEY = 'ado2_tutorial_enabled'; + if (localStorage.getItem(ENABLED_KEY) !== null) return; + getVideosList(1, 1).then(response => { + const hasVideos = response.items.some(v => v.result_movie_url?.trim()); + if (hasVideos) { + localStorage.setItem(ENABLED_KEY, 'false'); + window.dispatchEvent(new Event('ado2-tutorial-auto-disable')); + } + }).catch(() => {}); + }, [viewMode]); // 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인) useEffect(() => { @@ -266,6 +283,7 @@ const App: React.FC = () => { const handleStartAnalysis = async (url: string) => { if (!url.trim()) return; + setAfterLoadTarget('analysis'); setViewMode('loading'); setIsAnalysisComplete(false); setError(null); @@ -291,6 +309,7 @@ const App: React.FC = () => { // 업체명 자동완성으로 분석 시작 const handleAutocomplete = async (request: AutocompleteRequest) => { + setAfterLoadTarget('analysis'); setViewMode('loading'); setIsAnalysisComplete(false); setError(null); @@ -316,6 +335,7 @@ const App: React.FC = () => { // 업체명·주소 수동 입력으로 마케팅 분석 API 호출 const handleManualInput = async (businessName: string, address: string) => { + setAfterLoadTarget('generation_flow'); setViewMode('loading'); setIsAnalysisComplete(false); setError(null); @@ -442,7 +462,7 @@ const App: React.FC = () => { return ( setViewMode('analysis')} + onComplete={() => setViewMode(afterLoadTarget)} /> ); } diff --git a/src/components/SearchInputForm.tsx b/src/components/SearchInputForm.tsx new file mode 100644 index 0000000..e616416 --- /dev/null +++ b/src/components/SearchInputForm.tsx @@ -0,0 +1,342 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api'; +import { useSearchHistory } from './SearchHistory/useSearchHistory'; +import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown'; +import BusinessNameInputModal from './BusinessNameInputModal'; + +type SearchType = 'url' | 'name' | 'manual'; + +export type { SearchType }; + +const isValidUrl = (string: string): boolean => { + try { + const url = new URL(string); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +interface SearchInputFormProps { + onAnalyze?: (value: string, type: SearchType) => void; + onAutocomplete?: (data: AutocompleteRequest) => void; + onManualInput?: (businessName: string, address: string) => void; + /** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */ + onManualButtonClick?: () => void; + error?: string | null; +} + +const SearchInputForm: React.FC = ({ + onAnalyze, + onAutocomplete, + onManualInput, + onManualButtonClick, + error: externalError, +}) => { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(''); + const [searchType, setSearchType] = useState('name'); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [isManualModalOpen, setIsManualModalOpen] = useState(false); + const [localError, setLocalError] = useState(''); + 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 { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType); + const debounceRef = useRef(null); + const dropdownRef = useRef(null); + const autocompleteRef = useRef(null); + + 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 searchTypeOptions = [ + { value: 'url' as SearchType, label: 'URL' }, + { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') }, + ]; + + const getPlaceholder = () => + searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName'); + + const getGuideText = () => + searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName'); + + 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 { + setAutocompleteResults([]); + setShowAutocomplete(false); + } finally { + setIsAutocompleteLoading(false); + } + }, [searchType]); + + const handleSelectAutocomplete = (item: AccommodationSearchItem) => { + setInputValue(item.title.replace(/<[^>]*>/g, '')); + setSelectedItem(item); + setShowAutocomplete(false); + setAutocompleteResults([]); + setHighlightedIndex(-1); + }; + + 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(); + if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) { + handleSelectAutocomplete(autocompleteResults[highlightedIndex]); + } + return; + case 'Escape': + e.preventDefault(); + setShowAutocomplete(false); + setHighlightedIndex(-1); + return; + } + } + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + 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) { + onAutocomplete({ + address: selectedItem.address, + roadAddress: selectedItem.roadAddress, + title: selectedItem.title, + }); + } else if (onAnalyze) { + onAnalyze(inputValue, searchType); + } + }; + + const handleManualButtonClick = () => { + if (onManualButtonClick) { + onManualButtonClick(); + } else { + setIsManualModalOpen(true); + } + }; + + const error = externalError || localError; + + return ( +
+
+ {/* 드롭다운 */} +
+ + {isDropdownOpen && ( +
+ {searchTypeOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* 입력 필드 */} +
+ { + let value = e.target.value; + if (searchType === 'url') { + const urlMatch = value.match(/https?:\/\/\S+/); + if (urlMatch && urlMatch[0] !== value) value = urlMatch[0]; + } + setInputValue(value); + setSelectedItem(null); + setHighlightedIndex(-1); + hideOnInput(value); + 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); + openHistory(inputValue); + }} + onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }} + onKeyDown={handleKeyDown} + placeholder={getPlaceholder()} + className={`hero-input ${inputValue ? 'has-value' : ''}`} + /> + + {/* 최근 검색 히스토리 */} + {showHistory && !showAutocomplete && ( + { + closeHistory(); + if (item.type === 'url') { + setSearchType('url'); + setInputValue(item.value); + } else { + setSearchType('name'); + setInputValue(item.value); + setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' }); + } + }} + onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }} + className="hero-autocomplete-dropdown" + itemClassName="hero-autocomplete-item" + titleClassName="hero-autocomplete-title" + addressClassName="hero-autocomplete-address" + /> + )} + + {/* 자동완성 결과 */} + {showAutocomplete && searchType === 'name' && ( +
+ {isAutocompleteLoading ? ( +
{t('landing.hero.searching')}
+ ) : ( + autocompleteResults.map((item, index) => ( + + )) + )} +
+ )} +
+ + {/* 입력 clear 버튼 */} + {inputValue && ( + + )} +
+ + {getGuideText()} + + {error &&

{error}

} + + + + + + {/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */} + {!onManualButtonClick && isManualModalOpen && ( + setIsManualModalOpen(false)} + onSubmit={(businessName, address) => { + setIsManualModalOpen(false); + onManualInput?.(businessName, address); + }} + /> + )} +
+ ); +}; + +export default SearchInputForm; diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts index 478da87..0b2fdb5 100644 --- a/src/components/Tutorial/useTutorial.ts +++ b/src/components/Tutorial/useTutorial.ts @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps'; // 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환 @@ -189,6 +189,16 @@ export function useTutorial(): UseTutorialReturn { return getSeenKeys().includes(key); }, []); + // 영상 보유 사용자 자동 off 이벤트 수신 + useEffect(() => { + const handler = () => { + setIsActive(false); + setIsEnabled(false); + }; + window.addEventListener('ado2-tutorial-auto-disable', handler); + return () => window.removeEventListener('ado2-tutorial-auto-disable', handler); + }, []); + return { isActive, isEnabled, diff --git a/src/locales/en.json b/src/locales/en.json index 4162675..a6393c5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -213,6 +213,10 @@ "testDataLoading": "Loading...", "testData": "Test Data", "searchTypeManual": "Manual Input", + "errorSelectFromList": "Please select a business from the list.", + "errorUrlRequired": "Please enter a URL.", + "errorNameRequired": "Please enter a business name.", + "errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)", "manualModalTitle": "Enter Business Info", "manualLabelName": "Business Name", "manualLabelAddress": "Address", @@ -443,7 +447,7 @@ "title": "My Info", "tabBasic": "Basic Info", "tabPayment": "Payment Info", - "tabBusiness": "My Business & Social Channel Management", + "tabBusiness": "Social Channel Management", "basicPlaceholder": "Basic information settings are coming soon.", "paymentPlaceholder": "Payment information settings are coming soon.", "myBusiness": "My Business", diff --git a/src/locales/ko.json b/src/locales/ko.json index 1fd080f..26a79ed 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -56,7 +56,7 @@ }, "myInfo": { "myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." }, - "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." }, + "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." }, "connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." }, "ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." } }, @@ -213,6 +213,10 @@ "testDataLoading": "로딩 중...", "testData": "테스트 데이터", "searchTypeManual": "직접 입력", + "errorSelectFromList": "목록에서 업체를 선택해 주세요.", + "errorUrlRequired": "URL을 입력해주세요.", + "errorNameRequired": "업체명을 입력해주세요.", + "errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)", "manualModalTitle": "업체 정보 입력", "manualLabelName": "업체명", "manualLabelAddress": "주소", @@ -443,7 +447,7 @@ "title": "내 정보", "tabBasic": "기본 정보", "tabPayment": "결제 정보", - "tabBusiness": "내 비즈니스 & 소셜 채널 관리", + "tabBusiness": "소셜 채널 관리", "basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.", "paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.", "myBusiness": "내 비즈니스", diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx index ab32e8d..013b903 100755 --- a/src/pages/Analysis/LoadingSection.tsx +++ b/src/pages/Analysis/LoadingSection.tsx @@ -50,7 +50,7 @@ const LoadingSection: React.FC = ({ onComplete, isComplete
{/* Logo */}
- ADO2 + ADO2
{/* Loading Spinner and Text */} diff --git a/src/pages/Dashboard/MyInfoContent.tsx b/src/pages/Dashboard/MyInfoContent.tsx index 102e8a3..f2f4151 100644 --- a/src/pages/Dashboard/MyInfoContent.tsx +++ b/src/pages/Dashboard/MyInfoContent.tsx @@ -113,7 +113,7 @@ const MyInfoContent: React.FC = ({ initialTab }) => { const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0; const tabs = [ - { id: 'basic' as TabType, label: t('myInfo.tabBasic') }, + // { id: 'basic' as TabType, label: t('myInfo.tabBasic') }, { id: 'payment' as TabType, label: t('myInfo.tabPayment') }, { id: 'business' as TabType, label: t('myInfo.tabBusiness') }, ]; @@ -205,11 +205,11 @@ const MyInfoContent: React.FC = ({ initialTab }) => { {/* 탭 컨텐츠 */}
- {activeTab === 'basic' && ( + {/* {activeTab === 'basic' && (

{t('myInfo.basicPlaceholder')}

- )} + )} */} {activeTab === 'payment' && (
@@ -240,7 +240,7 @@ const MyInfoContent: React.FC = ({ initialTab }) => { {activeTab === 'business' && ( <> - {/* 내 비즈니스 섹션 */} + {/* 내 비즈니스 섹션

{t('myInfo.myBusiness')}

@@ -261,7 +261,7 @@ const MyInfoContent: React.FC = ({ initialTab }) => {
-
+
*/} {/* 소셜 채널 섹션 */}
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx index b4aa424..55e5319 100755 --- a/src/pages/Dashboard/SoundStudioContent.tsx +++ b/src/pages/Dashboard/SoundStudioContent.tsx @@ -26,6 +26,19 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli const MAX_RETRY_COUNT = 3; +const sanitizeError = (message: string, fallback: string): string => { + if ( + message.includes('Traceback') || + message.includes('File "/') || + message.includes('httpx.') || + message.includes('httpcore.') || + message.length > 200 + ) { + return fallback; + } + return message; +}; + const LANGUAGE_FLAGS: Record = { '한국어': '🇰🇷', 'English': '🇺🇸', @@ -131,7 +144,10 @@ const SoundStudioContent: React.FC = ({ } } else { setStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError')); + setErrorMessage(sanitizeError( + error instanceof Error ? error.message : '', + t('soundStudio.musicGenerationError') + )); setRetryCount(0); } } @@ -168,7 +184,10 @@ const SoundStudioContent: React.FC = ({ } catch (error) { console.error('Song regeneration failed:', error); setStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError')); + setErrorMessage(sanitizeError( + error instanceof Error ? error.message : '', + t('soundStudio.songRegenerationError') + )); setRetryCount(0); } }; @@ -362,7 +381,10 @@ const SoundStudioContent: React.FC = ({ } catch (error) { console.error('Music generation failed:', error); setStatus('error'); - setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError')); + setErrorMessage(sanitizeError( + error instanceof Error ? error.message : '', + t('soundStudio.musicGenerationError') + )); setRetryCount(0); } }; @@ -496,8 +518,8 @@ const SoundStudioContent: React.FC = ({ {['한국어', 'English', '中文'].map((lang) => ( - {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); - hideOnInput(value); - - // 업체명 검색일 때 자동완성 검색 (디바운스) - if (searchType === 'name') { - if (debounceRef.current) { - clearTimeout(debounceRef.current); - } - debounceRef.current = setTimeout(() => { - handleAutocompleteSearch(value); - }, 300); - } - }} - onPaste={handlePaste} - onFocus={() => { - if (searchType === 'name' && autocompleteResults.length > 0) { - setShowAutocomplete(true); - } - openHistory(inputValue); - }} - onBlur={() => setTimeout(() => closeHistory(), 150)} - placeholder={getPlaceholder()} - className="url-input-field" - /> - - {/* 최근 검색 히스토리 */} - {showHistory && !showAutocomplete && ( - { e.preventDefault(); e.stopPropagation(); deleteItem(value); }} - /> - )} - - {/* 자동완성 결과 */} - {showAutocomplete && searchType === 'name' && ( -
- {isAutocompleteLoading ? ( -
{t('urlInput.searching')}
- ) : ( - autocompleteResults.map((item, index) => ( - - )) - )} -
- )} -
-
- - {/* 안내 텍스트 */} -

- {getGuideText()} -

- - {/* 에러 메시지 */} - {error && ( -

{error}

- )} - - {/* 검색 버튼 */} - - - - + {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */} @@ -342,13 +60,6 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp {isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')} )} - - {isManualModalOpen && ( - setIsManualModalOpen(false)} - onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }} - /> - )} ); }; diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index 0dcea04..e2c9556 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -1,39 +1,19 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api'; +import { AutocompleteRequest, isLoggedIn } 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'; -import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; -import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; import BusinessNameInputModal from '../../components/BusinessNameInputModal'; - -type SearchType = 'url' | 'name' | 'manual'; +import LoginPromptModal from '../../components/LoginPromptModal'; +import SearchInputForm from '../../components/SearchInputForm'; +import { SearchType } from '../../components/SearchInputForm'; // 환경변수에서 테스트 모드 확인 const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; -interface HeroSectionProps { - onAnalyze?: (value: string, type?: SearchType) => void; - onAutocomplete?: (data: AutocompleteRequest) => void; - onManualInput?: (businessName: string, address: string) => 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; @@ -41,66 +21,39 @@ interface OrbConfig { 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 }, ]; +interface HeroSectionProps { + onAnalyze?: (value: string, type?: SearchType) => void; + onAutocomplete?: (data: AutocompleteRequest) => void; + onManualInput?: (businessName: string, address: string) => void; + onTestData?: (data: CrawlingResponse) => void; + onNext?: () => void; + error?: string | null; + scrollProgress?: number; +} + const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const { t, i18n } = useTranslation(); - const [inputValue, setInputValue] = useState(''); - const [searchType, setSearchType] = useState('name'); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isManualModalOpen, setIsManualModalOpen] = useState(false); - const [localError, setLocalError] = useState(''); + const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false); + const [testError, setTestError] = 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 { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType); - 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(); // 첫 방문 시 랜딩 튜토리얼 시작 @@ -113,101 +66,28 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on } }, []); - const searchTypeOptions = [ - { value: 'url' as SearchType, label: 'URL' }, - { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') }, - // { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') }, - ]; - - // 드롭다운 외부 클릭 감지 - 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); + // 테스트 데이터 로드 핸들러 + const handleTestData = async () => { + if (!onTestData) return; + setIsLoadingTest(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); + 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 { + setTestError(t('landing.hero.testDataLoadFailed')); } finally { - setIsAutocompleteLoading(false); - } - }, [searchType]); - - // 자동완성 항목 선택 - 업체 정보 저장 - const handleSelectAutocomplete = (item: AccommodationSearchItem) => { - setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거 - setSelectedItem(item); // 선택된 업체 정보 저장 - setShowAutocomplete(false); - setAutocompleteResults([]); - setHighlightedIndex(-1); - }; - - // 키보드 네비게이션 핸들러 - 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') { - e.preventDefault(); + setIsLoadingTest(false); } }; - // Random movement for orbs + // 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; @@ -215,17 +95,13 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on 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; @@ -233,94 +109,38 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on targetX = config.minX + Math.random() * rangeX; targetY = config.minY + Math.random() * rangeY; } - targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위) + targetScale = 0.9 + Math.random() * 0.2; }; 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(); - } - + 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); - } + 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 - 스크롤에 따라 빠르게 사라짐 */} + {/* Animated background orbs */}
= ({ onAnalyze, onAutocomplete, on
- {/* 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); - hideOnInput(value); - 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); - } - openHistory(inputValue); - }} - onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }} - onKeyDown={handleKeyDown} - placeholder={getPlaceholder()} - className={`hero-input ${inputValue ? 'has-value' : ''}`} - /> - - {/* 최근 검색 히스토리 */} - {showHistory && !showAutocomplete && ( - { - closeHistory(); - if (item.type === 'url') { - setSearchType('url'); - setInputValue(item.value); - } else { - setSearchType('name'); - setInputValue(item.value); - setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' }); - } - }} - onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }} - className="hero-autocomplete-dropdown" - itemClassName="hero-autocomplete-item" - titleClassName="hero-autocomplete-title" - addressClassName="hero-autocomplete-address" - /> - )} - - {/* 자동완성 결과 */} - {showAutocomplete && searchType === 'name' && ( -
- {isAutocompleteLoading ? ( -
{t('landing.hero.searching')}
- ) : ( - autocompleteResults.map((item, index) => ( - - )) - )} -
- )} -
- {inputValue && ( - - )} -
- {getGuideText()} - {error && ( -

{error}

- )} - - - - {/* 직접 입력 버튼 */} - -
+ { + if (isLoggedIn()) { + setIsManualModalOpen(true); + } else { + setIsLoginPromptOpen(true); + } + }} + error={externalError || testError || null} + />
{/* Footer Indicator */} - {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */} + {/* 테스트 버튼 */} {isTestPage && onTestData && ( - )} @@ -580,6 +221,10 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on }} /> )} + + {isLoginPromptOpen && ( + setIsLoginPromptOpen(false)} /> + )}
); }; diff --git a/src/pages/Login/LoginSection.tsx b/src/pages/Login/LoginSection.tsx index 5a1a64c..cf3cfdf 100755 --- a/src/pages/Login/LoginSection.tsx +++ b/src/pages/Login/LoginSection.tsx @@ -77,7 +77,7 @@ const LoginSection: React.FC = ({ onBack, onLogin }) => {
{/* Logo */}
- ADO2 + ADO2
{/* Error Message */}