From 1d855fd14c23562eb95dc070a56ea8b7479bda1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 29 May 2026 10:27:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=85=EC=B2=B4=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.css | 134 ++++++++++++++++++++++ src/App.tsx | 27 ++++- src/components/BusinessNameInputModal.tsx | 95 +++++++++++++++ src/locales/en.json | 20 +++- src/locales/ko.json | 20 +++- src/pages/Dashboard/GenerationFlow.tsx | 30 ++++- src/pages/Dashboard/UrlInputContent.tsx | 26 ++++- src/pages/Landing/HeroSection.tsx | 26 ++++- src/utils/api.ts | 31 +++++ 9 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 src/components/BusinessNameInputModal.tsx diff --git a/index.css b/index.css index 581818c..d3ddffa 100644 --- a/index.css +++ b/index.css @@ -12318,3 +12318,137 @@ background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.4); } + +/* BusinessNameInputModal */ +.manual-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.manual-modal { + background: #1a2a2b; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + width: 375px; + max-width: 92vw; + display: flex; + flex-direction: column; +} + +.manual-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.manual-modal-title { + font-size: 15px; + font-weight: 600; + color: #fff; +} + +.manual-modal-close { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 16px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.manual-modal-close:hover { + color: #fff; +} + +.manual-modal-body { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 20px 24px; +} + +.manual-modal-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.manual-modal-label { + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.7); +} + +.manual-modal-input { + width: 100%; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(155, 202, 204, 0.25); + background: rgba(255, 255, 255, 0.06); + color: #fff; + font-size: 14px; + outline: none; + box-sizing: border-box; + transition: border-color 0.15s; +} + +.manual-modal-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.manual-modal-input:focus { + border-color: #9BCACC; +} + +.manual-modal-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.manual-modal-cancel { + flex: 1; + padding: 11px 0; + border-radius: 8px; + border: 1px solid rgba(155, 202, 204, 0.3); + background: transparent; + color: rgba(255, 255, 255, 0.6); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.manual-modal-cancel:hover { + background: rgba(155, 202, 204, 0.08); +} + +.manual-modal-submit { + flex: 2; + padding: 11px 0; + border-radius: 8px; + border: none; + background: #9BCACC; + color: #1a2a2b; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.manual-modal-submit:hover:not(:disabled) { + background: #b0d8da; +} + +.manual-modal-submit:disabled { + opacity: 0.35; + cursor: not-allowed; +} diff --git a/src/App.tsx b/src/App.tsx index 15d8c60..f9e9046 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ 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, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; +import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { CrawlingResponse } from './types/api'; @@ -314,6 +314,30 @@ const App: React.FC = () => { } }; + // 업체명·주소 수동 입력으로 마케팅 분석 API 호출 + const handleManualInput = async (businessName: string, address: string) => { + setViewMode('loading'); + setIsAnalysisComplete(false); + setError(null); + + try { + const data = await marketingAnalysis(businessName, address); + + if (!validateCrawlingResponse(data)) { + throw new Error(t('app.autocompleteError')); + } + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address }); + setIsAnalysisComplete(true); + } catch (err) { + console.error('Marketing analysis failed:', err); + setError(t('app.autocompleteError')); + setViewMode('landing'); + } + }; + // 테스트 데이터로 브랜드 분석 페이지 이동 const handleTestData = (data: CrawlingResponse) => { const tagged = { ...data, _isTestData: true }; @@ -462,6 +486,7 @@ const App: React.FC = () => { scrollToSection(1)} error={error} diff --git a/src/components/BusinessNameInputModal.tsx b/src/components/BusinessNameInputModal.tsx new file mode 100644 index 0000000..401c937 --- /dev/null +++ b/src/components/BusinessNameInputModal.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface BusinessNameInputModalProps { + onClose: () => void; + onSubmit: (businessName: string, address: string) => void; +} + +const BusinessNameInputModal: React.FC = ({ onClose, onSubmit }) => { + const { t } = useTranslation(); + const [businessName, setBusinessName] = useState(''); + const [address, setAddress] = useState(''); + + useEffect(() => { + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = ''; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + const isValid = businessName.trim().length > 0 && address.trim().length > 0; + + const handleSubmit = () => { + if (!isValid) return; + onSubmit(businessName.trim(), address.trim()); + onClose(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isValid) handleSubmit(); + }; + + return ( +
+
e.stopPropagation()}> +
+ {t('landing.hero.manualModalTitle')} + +
+ +
+
+ + setBusinessName(e.target.value)} + onKeyDown={handleKeyDown} + maxLength={50} + autoFocus + /> +
+ +
+ + setAddress(e.target.value)} + onKeyDown={handleKeyDown} + maxLength={100} + /> +
+ +
+ + +
+
+
+
+ ); +}; + +export default BusinessNameInputModal; diff --git a/src/locales/en.json b/src/locales/en.json index f3cdc1e..6676ca5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -95,8 +95,8 @@ "footer": { "company":"O2O Inc.", "businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin", - "headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea", - "researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea", + "headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea", + "researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea", "phone": "Tel : 070-4260-8310 | 010-2755-6463", "email": "Email : o2oteam@o2o.kr", "privacyPolicy": "Privacy Policy", @@ -169,7 +169,13 @@ "testDataLoading": "Loading...", "testData": "Test Data", "testDataLoadFailed": "Failed to load test data.", - "searching": "Searching..." + "searching": "Searching...", + "searchTypeManual": "Manual Input", + "manualModalTitle": "Enter Business Info", + "manualLabelName": "Business Name", + "manualLabelAddress": "Address", + "manualPlaceholderName": "Enter the business name", + "manualPlaceholderAddress": "Enter the address" }, "welcome": { "title": "Welcome to ADO2.AI", @@ -200,7 +206,13 @@ "searchButton": "Search", "searching": "Searching...", "testDataLoading": "Loading...", - "testData": "Test Data" + "testData": "Test Data", + "searchTypeManual": "Manual Input", + "manualModalTitle": "Enter Business Info", + "manualLabelName": "Business Name", + "manualLabelAddress": "Address", + "manualPlaceholderName": "Enter the business name", + "manualPlaceholderAddress": "Enter the address" }, "assetManagement": { "title": "Brand Assets", diff --git a/src/locales/ko.json b/src/locales/ko.json index 9def045..e562f07 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -95,8 +95,8 @@ "footer": { "company":"㈜에이아이오투오", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", - "headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", - "researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", + "headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", + "researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", "phone": "전화 : 070-4260-8310 | 010-2755-6463", "email": "이메일 : o2oteam@o2o.kr", "privacyPolicy": "개인정보처리방침", @@ -169,7 +169,13 @@ "testDataLoading": "로딩 중...", "testData": "테스트 데이터", "testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.", - "searching": "검색 중..." + "searching": "검색 중...", + "searchTypeManual": "직접 입력", + "manualModalTitle": "업체 정보 직접 입력", + "manualLabelName": "업체명", + "manualLabelAddress": "주소", + "manualPlaceholderName": "업체명을 입력하세요.", + "manualPlaceholderAddress": "주소를 입력하세요." }, "welcome": { "title": "ADO2.AI에 오신 것을 환영합니다.", @@ -200,7 +206,13 @@ "searchButton": "검색하기", "searching": "검색 중...", "testDataLoading": "로딩 중...", - "testData": "테스트 데이터" + "testData": "테스트 데이터", + "searchTypeManual": "직접 입력", + "manualModalTitle": "업체 정보 직접 입력", + "manualLabelName": "업체명", + "manualLabelAddress": "주소", + "manualPlaceholderName": "업체명을 입력하세요.", + "manualPlaceholderAddress": "주소를 입력하세요." }, "assetManagement": { "title": "브랜드 에셋", diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 762fb03..2dc108a 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -15,7 +15,7 @@ import ContentCalendarContent from './ContentCalendarContent'; import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; -import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; +import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; @@ -261,6 +261,33 @@ const GenerationFlow: React.FC = ({ } }; + // 업체명·주소 수동 입력으로 마케팅 분석 API 호출 + const handleManualInput = async (businessName: string, address: string) => { + goToWizardStep(-1); + setIsAnalysisComplete(false); + setAnalysisError(null); + + try { + const data = await marketingAnalysis(businessName, address); + + if (data.processed_info) { + data.processed_info.customer_name = data.processed_info.customer_name || businessName; + data.processed_info.region = data.processed_info.region || ''; + data.processed_info.detail_region_info = data.processed_info.detail_region_info || ''; + } + data.image_list = data.image_list || []; + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address }); + setIsAnalysisComplete(true); + } catch (err) { + console.error('Marketing analysis error:', err); + setAnalysisError(t('app.autocompleteError')); + goToWizardStep(-2); + } + }; + // 테스트용 랜덤 m_id 생성 (99 ~ 300) const generateRandomMId = (): number => { return Math.floor(Math.random() * (300 - 99 + 1)) + 99; @@ -411,6 +438,7 @@ const GenerationFlow: React.FC = ({ diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx index 063524f..fd71e2f 100644 --- a/src/pages/Dashboard/UrlInputContent.tsx +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -4,8 +4,9 @@ import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } fro import { CrawlingResponse } from '../../types/api'; import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; +import BusinessNameInputModal from '../../components/BusinessNameInputModal'; -type SearchType = 'url' | 'name'; +type SearchType = 'url' | 'name' | 'manual'; // 환경변수에서 테스트 모드 확인 const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; @@ -13,11 +14,12 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; interface UrlInputContentProps { onAnalyze: (value: string, type?: SearchType) => void; onAutocomplete?: (data: AutocompleteRequest) => void; + onManualInput?: (businessName: string, address: string) => void; onTestData?: (data: CrawlingResponse) => void; error: string | null; } -const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, onTestData, error }) => { +const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => { const { t, i18n } = useTranslation(); const [inputValue, setInputValue] = useState(''); const [searchType, setSearchType] = useState('name'); @@ -27,7 +29,8 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp const [showAutocomplete, setShowAutocomplete] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const [isLoadingTest, setIsLoadingTest] = useState(false); - const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType); + const [isManualModalOpen, setIsManualModalOpen] = useState(false); + const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType); const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => { closeHistory(); @@ -67,6 +70,7 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp const searchTypeOptions = [ { value: 'url' as SearchType, label: 'URL' }, { value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') }, + { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') }, ]; const getPlaceholder = () => { @@ -215,8 +219,13 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp type="button" className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`} onClick={() => { - setSearchType(option.value); - setIsDropdownOpen(false); + if (option.value === 'manual') { + setIsManualModalOpen(true); + setIsDropdownOpen(false); + } else { + setSearchType(option.value); + setIsDropdownOpen(false); + } }} > {option.label} @@ -329,6 +338,13 @@ 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 4c22144..7beca64 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -8,8 +8,9 @@ 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'; +type SearchType = 'url' | 'name' | 'manual'; // 환경변수에서 테스트 모드 확인 const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; @@ -17,6 +18,7 @@ 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; @@ -62,11 +64,12 @@ const orbConfigs: OrbConfig[] = [ { 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 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 [isLoadingTest, setIsLoadingTest] = useState(false); @@ -86,7 +89,7 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on setIsLoadingTest(false); } }; - const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType); + 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); @@ -113,6 +116,7 @@ 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') }, ]; // 드롭다운 외부 클릭 감지 @@ -377,8 +381,13 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on type="button" className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`} onClick={() => { - setSearchType(option.value); - setIsDropdownOpen(false); + if (option.value === 'manual') { + setIsManualModalOpen(true); + setIsDropdownOpen(false); + } else { + setSearchType(option.value); + setIsDropdownOpen(false); + } }} > {option.label} @@ -555,6 +564,13 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on groupProgress={tutorial.groupProgress} /> )} + + {isManualModalOpen && ( + setIsManualModalOpen(false)} + onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }} + /> + )} ); }; diff --git a/src/utils/api.ts b/src/utils/api.ts index d9925cf..76ec24f 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -900,6 +900,37 @@ export async function autocomplete(request: AutocompleteRequest): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); + + try { + const response = await authenticatedFetch(`${API_URL}/marketing`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ store_name: storeName, address }), + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('요청 시간이 초과되었습니다. 다시 시도해주세요.'); + } + throw error; + } +} + // ============================================ // Social OAuth TOKEN_EXPIRED 처리 // ============================================