업체 직접입력 기능 추가

main
김성경 2026-05-29 10:27:09 +09:00
parent 0ea14da748
commit 1d855fd14c
9 changed files with 389 additions and 20 deletions

134
index.css
View File

@ -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;
}

View File

@ -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 = () => {
<HeroSection
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
onNext={() => scrollToSection(1)}
error={error}

View File

@ -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<BusinessNameInputModalProps> = ({ 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 (
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button>
</div>
<div className="manual-modal-body">
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderName')}
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={50}
autoFocus
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderAddress')}
value={address}
onChange={e => setAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-modal-submit"
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>
</div>
</div>
);
};
export default BusinessNameInputModal;

View File

@ -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",

View File

@ -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": "브랜드 에셋",

View File

@ -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<GenerationFlowProps> = ({
}
};
// 업체명·주소 수동 입력으로 마케팅 분석 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<GenerationFlowProps> = ({
<UrlInputContent
onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData}
error={analysisError}
/>

View File

@ -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<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
@ -27,7 +29,8 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(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<UrlInputContentProps> = ({ 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<UrlInputContentProps> = ({ onAnalyze, onAutocomp
type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}}
>
{option.label}
@ -329,6 +338,13 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div>
);
};

View File

@ -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<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('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<HeroSectionProps> = ({ 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<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
@ -113,6 +116,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ 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<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}}
>
{option.label}
@ -555,6 +564,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
groupProgress={tutorial.groupProgress}
/>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div>
);
};

View File

@ -900,6 +900,37 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
}
}
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
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 처리
// ============================================