자동완성 기능 추가 .

main
hbyang 2026-01-29 16:06:35 +09:00
parent 7ee911f0aa
commit 6d89a28982
5 changed files with 84 additions and 56 deletions

View File

@ -292,11 +292,11 @@ const LayoutGridIcon = () => (
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => { const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { processed_info, marketing_analysis } = data; const { processed_info, marketing_analysis } = data;
const tags = marketing_analysis.tags || []; const tags = marketing_analysis?.tags || [];
const facilities = marketing_analysis.facilities || []; const facilities = marketing_analysis?.facilities || [];
const reportSections = useMemo( const reportSections = useMemo(
() => splitMarkdownSections(marketing_analysis.report || ''), () => splitMarkdownSections(marketing_analysis?.report || ''),
[marketing_analysis.report] [marketing_analysis?.report]
); );
const locationAnalysis = const locationAnalysis =
pickSectionContent(reportSections, ['지역', '입지']) || reportSections[0]?.content || ''; pickSectionContent(reportSections, ['지역', '입지']) || reportSections[0]?.content || '';
@ -332,7 +332,7 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</div> </div>
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white"> </h1> <h1 className="text-4xl font-bold mb-3 tracking-tight text-white"> </h1>
<p className="text-brand-muted text-lg max-w-xl mx-auto"> <p className="text-brand-muted text-lg max-w-xl mx-auto">
<span className="text-brand-accent font-semibold">AI </span> {processed_info.customer_name} . <span className="text-brand-accent font-semibold">AI </span> {processed_info?.customer_name || '브랜드'} .
</p> </p>
</div> </div>
@ -346,12 +346,12 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</span> </span>
</div> </div>
<h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{processed_info.customer_name}</h2> <h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{processed_info?.customer_name || '브랜드명'}</h2>
<div className="flex items-start gap-2 text-brand-muted text-sm mb-6"> <div className="flex items-start gap-2 text-brand-muted text-sm mb-6">
<MapPinIcon /> <MapPinIcon />
<div> <div>
<p>{processed_info.detail_region_info || '주소 정보 없음'}</p> <p>{processed_info?.detail_region_info || '주소 정보 없음'}</p>
<p className="opacity-70">{processed_info.region}</p> <p className="opacity-70">{processed_info?.region || ''}</p>
</div> </div>
</div> </div>

View File

@ -11,7 +11,7 @@ import ADO2ContentsPage from './ADO2ContentsPage';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse } from '../../types/api'; import { ImageItem, CrawlingResponse } from '../../types/api';
import { crawlUrl } from '../../utils/api'; import { crawlUrl, autocomplete, AutocompleteRequest } from '../../utils/api';
const WIZARD_STEP_KEY = 'castad_wizard_step'; const WIZARD_STEP_KEY = 'castad_wizard_step';
const ACTIVE_ITEM_KEY = 'castad_active_item'; const ACTIVE_ITEM_KEY = 'castad_active_item';
@ -159,6 +159,37 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
localStorage.setItem(WIZARD_STEP_KEY, step.toString()); localStorage.setItem(WIZARD_STEP_KEY, step.toString());
}; };
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
goToWizardStep(-1); // 로딩 상태로
setAnalysisError(null);
try {
const data = await autocomplete(request);
// 기본값 보장
if (data.marketing_analysis) {
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
data.marketing_analysis.report = data.marketing_analysis.report || '';
}
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
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));
goToWizardStep(0); // 브랜드 분석 결과로
} catch (err) {
console.error('Autocomplete error:', err);
setAnalysisError(err instanceof Error ? err.message : '업체 정보 조회에 실패했습니다.');
goToWizardStep(-2); // URL 입력으로 돌아가기
}
};
// URL 분석 시작 // URL 분석 시작
const handleStartAnalysis = async (url: string) => { const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return; if (!url.trim()) return;
@ -234,6 +265,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return ( return (
<UrlInputContent <UrlInputContent
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete}
error={analysisError} error={analysisError}
/> />
); );

View File

@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
type SearchType = 'url' | 'name'; type SearchType = 'url' | 'name';
@ -14,19 +14,13 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('url'); const [searchType, setSearchType] = useState<SearchType>('url');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]); const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null); const debounceRef = useRef<NodeJS.Timeout | null>(null);
const autocompleteRef = useRef<HTMLDivElement>(null); const autocompleteRef = useRef<HTMLDivElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
onAnalyze(inputValue.trim(), searchType);
}
};
const searchTypeOptions = [ const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' }, { value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: '업체명' }, { value: 'name' as SearchType, label: '업체명' },
@ -54,7 +48,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
setIsAutocompleteLoading(true); setIsAutocompleteLoading(true);
try { try {
const response = await searchNaverLocal(query); const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []); setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0); setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) { } catch (error) {
@ -66,20 +60,30 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
} }
}, [searchType]); }, [searchType]);
// 자동완성 항목 선택 // 자동완성 항목 선택 - 업체 정보 저장
const handleSelectAutocomplete = (item: NaverLocalSearchItem) => { const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
const request: AutocompleteRequest = {
address: item.address,
roadAddress: item.roadAddress,
title: item.title,
};
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거 setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
setSelectedItem(item); // 선택된 업체 정보 저장
setShowAutocomplete(false); setShowAutocomplete(false);
setAutocompleteResults([]); setAutocompleteResults([]);
};
if (onAutocomplete) { // 폼 제출 처리
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim()) return;
if (searchType === 'name' && selectedItem && onAutocomplete) {
// 업체명 검색인 경우 autocomplete API 호출
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request); onAutocomplete(request);
} else {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue.trim(), searchType);
} }
}; };
@ -185,6 +189,11 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
</div> </div>
)} )}
</div> </div>
{/* 검색 버튼 */}
<button type="submit" className="url-input-button">
</button>
</div> </div>
{/* 에러 메시지 */} {/* 에러 메시지 */}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { searchNaverLocal, NaverLocalSearchItem, AutocompleteRequest } from '../../utils/api'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
type SearchType = 'url' | 'name'; type SearchType = 'url' | 'name';
@ -57,7 +57,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<NaverLocalSearchItem[]>([]); const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
@ -95,7 +95,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsAutocompleteLoading(true); setIsAutocompleteLoading(true);
try { try {
const response = await searchNaverLocal(query); const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []); setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0); setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) { } catch (error) {
@ -108,7 +108,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}, [searchType]); }, [searchType]);
// 자동완성 항목 선택 // 자동완성 항목 선택
const handleSelectAutocomplete = async (item: NaverLocalSearchItem) => { const handleSelectAutocomplete = async (item: AccommodationSearchItem) => {
const request: AutocompleteRequest = { const request: AutocompleteRequest = {
address: item.address, address: item.address,
roadAddress: item.roadAddress, roadAddress: item.roadAddress,

View File

@ -446,7 +446,6 @@ export function clearTokens() {
// 인증 헤더 생성 // 인증 헤더 생성
function getAuthHeader(): HeadersInit { function getAuthHeader(): HeadersInit {
const token = getAccessToken(); const token = getAccessToken();
console.log('[Auth] Token exists:', !!token, token ? `${token.substring(0, 20)}...` : 'null');
return token ? { 'Authorization': `Bearer ${token}` } : {}; return token ? { 'Authorization': `Bearer ${token}` } : {};
} }
@ -489,14 +488,10 @@ export async function kakaoCallback(code: string): Promise<KakaoCallbackResponse
} }
const data: KakaoCallbackResponse = await verifyResponse.json(); const data: KakaoCallbackResponse = await verifyResponse.json();
console.log('[Auth] Kakao verify response:', {
hasAccessToken: !!data.access_token,
hasRefreshToken: !!data.refresh_token
});
// 토큰 저장 // 토큰 저장
saveTokens(data.access_token, data.refresh_token); saveTokens(data.access_token, data.refresh_token);
console.log('[Auth] Tokens saved to localStorage');
return data; return data;
} }
@ -592,27 +587,19 @@ export function isLoggedIn(): boolean {
} }
// ============================================ // ============================================
// 네이버 지역 검색 & 자동완성 API // 숙소 검색 & 자동완성 API
// ============================================ // ============================================
export interface NaverLocalSearchItem { export interface AccommodationSearchItem {
title: string;
link: string;
category: string;
description: string;
telephone: string;
address: string; address: string;
roadAddress: string; roadAddress: string;
mapx: string; title: string;
mapy: string;
} }
export interface NaverLocalSearchResponse { export interface AccommodationSearchResponse {
lastBuildDate: string; count: number;
total: number; items: AccommodationSearchItem[];
start: number; query: string;
display: number;
items: NaverLocalSearchItem[];
} }
export interface AutocompleteRequest { export interface AutocompleteRequest {
@ -621,9 +608,9 @@ export interface AutocompleteRequest {
title: string; title: string;
} }
// 네이버 지역 검색 API (백엔드 프록시 경유) // 숙소 검색 API (업체명 자동완성용)
export async function searchNaverLocal(query: string): Promise<NaverLocalSearchResponse> { export async function searchAccommodation(query: string): Promise<AccommodationSearchResponse> {
const response = await fetch(`${API_URL}/naver/local/search?query=${encodeURIComponent(query)}`, { const response = await fetch(`${API_URL}/search/accommodation?query=${encodeURIComponent(query)}`, {
method: 'GET', method: 'GET',
headers: { headers: {
...getAuthHeader(), ...getAuthHeader(),