직접입력시 업종 추가

main
김성경 2026-07-03 16:00:37 +09:00
parent 2f720dfa92
commit bd92eb2226
14 changed files with 79 additions and 106 deletions

View File

@ -334,14 +334,14 @@ const App: React.FC = () => {
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
const handleManualInput = async (businessName: string, address: string, category: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address);
const data = await marketingAnalysis(businessName, address, category);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));

View File

@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string) => void;
onSubmit: (businessName: string, address: string, category: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
@ -13,6 +13,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const [businessName, setBusinessName] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState('');
const [category, setCategory] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => {
@ -41,7 +42,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const handleSubmit = () => {
if (!isValid) return;
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress);
onSubmit(businessName.trim(), fullAddress, category.trim());
onClose();
};
@ -104,6 +105,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelCategory')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderCategory')}
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={30}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}

View File

@ -21,7 +21,7 @@ const isValidUrl = (string: string): boolean => {
interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void;
error?: string | null;
@ -275,9 +275,9 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
{!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => {
onSubmit={(businessName, address, category) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address);
onManualInput?.(businessName, address, category);
}}
/>
)}

View File

@ -203,10 +203,12 @@
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
},
"welcome": {
"title": "Welcome to ADO2.AI",
@ -246,10 +248,12 @@
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)",
"manualPlaceholderCategory": "Enter the business category (e.g. pension, cafe, salon)"
},
"assetManagement": {
"title": "Brand Assets",

View File

@ -202,10 +202,12 @@
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
@ -245,10 +247,12 @@
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
},
"assetManagement": {
"title": "브랜드 에셋",

View File

@ -22,7 +22,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100);
});
}, 150);
}, 200);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};

View File

@ -263,13 +263,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
};
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
const handleManualInput = async (businessName: string, address: string, category: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
const data = await marketingAnalysis(businessName, address);
const data = await marketingAnalysis(businessName, address, category);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;

View File

@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete, downloadSong } from '../../utils/api';
import { generateLyric, waitForLyricComplete, generateSong, waitForSongComplete } from '../../utils/api';
import { LANGUAGE_MAP } from '../../types/api';
interface BusinessInfo {
@ -107,7 +107,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [selectedGenre, setSelectedGenre] = useState('자동 선택');
const [usedGenre, setUsedGenre] = useState('');
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle');
const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -118,7 +117,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// 완료 데이터를 localStorage에 저장
@ -142,27 +140,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const parsed = JSON.parse(saved);
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
// 저장된 URL로 일단 복원 후, 서버에서 최신 URL 재조회 시도
// 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
setSongTaskId(parsed.songTaskId);
setAudioUrl(parsed.audioUrl);
setLyrics(parsed.lyrics ?? '');
setStatus('complete');
// downloadSong으로 최신 URL을 재조회해 갱신 (만료 방지)
downloadSong(parsed.songTaskId)
.then((res) => {
if (res.success && res.song_result_url) {
setAudioUrl(res.song_result_url);
// 저장값도 갱신
localStorage.setItem(SONG_GENERATION_KEY, JSON.stringify({
...parsed,
audioUrl: res.song_result_url,
}));
}
})
.catch(() => {
// 재조회 실패 시 저장된 URL 그대로 사용
});
} catch {
// 파싱 실패 시 무시
}
@ -276,41 +259,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}
};
const handleMove = (clientX: number) => {
if (!progressBarRef.current || !audioRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const newProgress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
const newTime = (newProgress / 100) * duration;
audioRef.current.currentTime = newTime;
setProgress(newProgress);
setCurrentTime(newTime);
};
const onMouseDown = (e: React.MouseEvent) => {
if (!audioUrl) return;
setIsDragging(true);
handleMove(e.clientX);
};
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
if (isDragging) handleMove(e.clientX);
};
const onMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [isDragging]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
@ -489,6 +437,24 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling';
// 드래그로 인한 잦은 리렌더에서도 가사 파싱(정규식/배열 처리)이 매번 재실행되지 않도록 메모이제이션
const lyricsSections = useMemo(() => {
if (!lyrics) return [];
const lines = lyrics.split('\n').filter(l => l.trim());
const sections: { tag: string | null; lines: string[] }[] = [];
lines.forEach(line => {
const tagMatch = line.trim().match(/^\[(.+)\]$/);
if (tagMatch) {
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
} else if (sections.length === 0) {
sections.push({ tag: null, lines: [line] });
} else {
sections[sections.length - 1].lines.push(line);
}
});
return sections.filter(s => s.lines.length > 0);
}, [lyrics]);
// status 변경 시 부모에 알림
useEffect(() => {
onStatusChange?.(status);
@ -701,9 +667,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)}
</button>
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
<div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
>
<div
@ -719,30 +684,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */}
<div className="lyrics-display">
{lyrics ? (() => {
const lines = lyrics.split('\n').filter(l => l.trim());
const sections: { tag: string | null; lines: string[] }[] = [];
lines.forEach(line => {
const tagMatch = line.trim().match(/^\[(.+)\]$/);
if (tagMatch) {
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] });
} else if (sections.length === 0) {
sections.push({ tag: null, lines: [line] });
} else {
sections[sections.length - 1].lines.push(line);
}
});
return (
<div className="lyrics-paragraphs">
{sections.filter(s => s.lines.length > 0).map((section, i) => (
<div key={i} className="lyrics-section">
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
);
})() : (
{lyrics ? (
<div className="lyrics-paragraphs">
{lyricsSections.map((section, i) => (
<div key={i} className="lyrics-section">
{section.tag && <span className="lyrics-tag">{section.tag}</span>}
<p className="lyrics-paragraph">{section.lines.join('\n')}</p>
</div>
))}
</div>
) : (
<div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
</div>

View File

@ -5,7 +5,7 @@ import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
interface UrlInputContentProps {
onAnalyze: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
error: string | null;
}

View File

@ -35,7 +35,7 @@ const orbConfigs: OrbConfig[] = [
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onManualInput?: (businessName: string, address: string, category: string) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
@ -184,10 +184,10 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => {
onSubmit={(businessName, address, category) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address);
onManualInput?.(businessName, address, category);
}}
/>
)}

View File

@ -63,7 +63,7 @@
height: 100%;
display: flex;
flex-direction: column;
background-color: #002224;
background-color: var(--color-bg-darker);
overflow-y: auto;
}

View File

@ -910,7 +910,7 @@
}
.comp2-lyrics-tag {
font-size: 12px;
font-size: 14px;
font-weight: 700;
color: #379599;
letter-spacing: 0.05em;

View File

@ -550,7 +550,7 @@
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 0.5rem;
scrollbar-color: #046266 transparent;
}
@ -810,7 +810,7 @@
/* Asset Page Layout */
.asset-page {
height: 100%;
background-color: #002224;
background-color: var(--color-darker);
position: relative;
}

View File

@ -911,7 +911,7 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
}
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
export async function marketingAnalysis(storeName: string, address: string, category = ''): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
@ -921,7 +921,7 @@ export async function marketingAnalysis(storeName: string, address: string): Pro
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address }),
body: JSON.stringify({ store_name: storeName, address, category }),
signal: controller.signal,
});