직접입력시 업종 추가

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

View File

@ -5,7 +5,7 @@ import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps { interface BusinessNameInputModalProps {
onClose: () => void; onClose: () => void;
onSubmit: (businessName: string, address: string) => void; onSubmit: (businessName: string, address: string, category: string) => void;
} }
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => { const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
@ -13,6 +13,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const [businessName, setBusinessName] = useState(''); const [businessName, setBusinessName] = useState('');
const [selectedCity, setSelectedCity] = useState(''); const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState(''); const [detailAddress, setDetailAddress] = useState('');
const [category, setCategory] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false); const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
@ -41,7 +42,7 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
const handleSubmit = () => { const handleSubmit = () => {
if (!isValid) return; if (!isValid) return;
const fullAddress = `${selectedCity} ${detailAddress.trim()}`; const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress); onSubmit(businessName.trim(), fullAddress, category.trim());
onClose(); onClose();
}; };
@ -104,6 +105,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
/> />
</div> </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"> <div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}> <button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')} {t('common.cancel')}

View File

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

View File

@ -203,10 +203,12 @@
"manualLabelAddress": "Address", "manualLabelAddress": "Address",
"manualLabelRegion": "Region", "manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address", "manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name", "manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address", "manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region", "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": { "welcome": {
"title": "Welcome to ADO2.AI", "title": "Welcome to ADO2.AI",
@ -246,10 +248,12 @@
"manualLabelAddress": "Address", "manualLabelAddress": "Address",
"manualLabelRegion": "Region", "manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address", "manualLabelDetail": "Detail Address",
"manualLabelCategory": "Category",
"manualPlaceholderName": "Enter the business name", "manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address", "manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region", "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": { "assetManagement": {
"title": "Brand Assets", "title": "Brand Assets",

View File

@ -202,10 +202,12 @@
"manualLabelAddress": "주소", "manualLabelAddress": "주소",
"manualLabelRegion": "지역", "manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소", "manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.", "manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.", "manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.", "manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)" "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"welcome": { "welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.", "title": "ADO2.AI에 오신 것을 환영합니다.",
@ -245,10 +247,12 @@
"manualLabelAddress": "주소", "manualLabelAddress": "주소",
"manualLabelRegion": "지역", "manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소", "manualLabelDetail": "상세 주소",
"manualLabelCategory": "업종",
"manualPlaceholderName": "업체명을 입력하세요.", "manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요.", "manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.", "manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)" "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)",
"manualPlaceholderCategory": "업종을 입력하세요. (예: 펜션, 카페, 미용실)"
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "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; const increment = prev < 70 ? 0.5 : prev < 90 ? 0.2 : 0.1;
return Math.min(prev + increment, 100); return Math.min(prev + increment, 100);
}); });
}, 150); }, 200);
return () => { return () => {
if (intervalRef.current) clearInterval(intervalRef.current); if (intervalRef.current) clearInterval(intervalRef.current);
}; };

View File

@ -263,13 +263,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출 // 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => { const handleManualInput = async (businessName: string, address: string, category: string) => {
goToWizardStep(-1); goToWizardStep(-1);
setIsAnalysisComplete(false); setIsAnalysisComplete(false);
setAnalysisError(null); setAnalysisError(null);
try { try {
const data = await marketingAnalysis(businessName, address); const data = await marketingAnalysis(businessName, address, category);
if (data.processed_info) { if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName; 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 { 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'; import { LANGUAGE_MAP } from '../../types/api';
interface BusinessInfo { interface BusinessInfo {
@ -107,7 +107,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [selectedGenre, setSelectedGenre] = useState('자동 선택'); const [selectedGenre, setSelectedGenre] = useState('자동 선택');
const [usedGenre, setUsedGenre] = useState(''); const [usedGenre, setUsedGenre] = useState('');
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState<GenerationStatus>('idle'); const [status, setStatus] = useState<GenerationStatus>('idle');
const [lyrics, setLyrics] = useState(''); const [lyrics, setLyrics] = useState('');
const [audioUrl, setAudioUrl] = useState<string | null>(null); const [audioUrl, setAudioUrl] = useState<string | null>(null);
@ -118,7 +117,6 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const [songTaskId, setSongTaskId] = useState<string | null>(null); const [songTaskId, setSongTaskId] = useState<string | null>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
// 완료 데이터를 localStorage에 저장 // 완료 데이터를 localStorage에 저장
@ -142,27 +140,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const parsed = JSON.parse(saved); const parsed = JSON.parse(saved);
if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return; if (parsed.status !== 'complete' || !parsed.audioUrl || !parsed.songTaskId) return;
// 저장된 URL로 일단 복원 후, 서버에서 최신 URL 재조회 시도 // 저장된 URL로 복원 (public Blob URL이라 만료되지 않음)
// 백엔드에 /song/download 엔드포인트가 없으므로 재조회하지 않는다.
setSongTaskId(parsed.songTaskId); setSongTaskId(parsed.songTaskId);
setAudioUrl(parsed.audioUrl); setAudioUrl(parsed.audioUrl);
setLyrics(parsed.lyrics ?? ''); setLyrics(parsed.lyrics ?? '');
setStatus('complete'); 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 { } 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(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
@ -489,6 +437,24 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const isGenerating = status === 'generating_lyric' || status === 'generating_song' || status === 'polling'; 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 변경 시 부모에 알림 // status 변경 시 부모에 알림
useEffect(() => { useEffect(() => {
onStatusChange?.(status); onStatusChange?.(status);
@ -701,9 +667,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
)} )}
</button> </button>
{/* 진행 표시 전용 — Azure Blob 익명 응답에 Accept-Ranges가 없어 seek가 불안정하므로 드래그 미지원 */}
<div <div
ref={progressBarRef}
onMouseDown={onMouseDown}
className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`} className={`audio-progress-container ${!audioUrl ? 'disabled' : ''}`}
> >
<div <div
@ -719,30 +684,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Lyrics Display */} {/* Lyrics Display */}
<div className="lyrics-display"> <div className="lyrics-display">
{lyrics ? (() => { {lyrics ? (
const lines = lyrics.split('\n').filter(l => l.trim()); <div className="lyrics-paragraphs">
const sections: { tag: string | null; lines: string[] }[] = []; {lyricsSections.map((section, i) => (
lines.forEach(line => { <div key={i} className="lyrics-section">
const tagMatch = line.trim().match(/^\[(.+)\]$/); {section.tag && <span className="lyrics-tag">{section.tag}</span>}
if (tagMatch) { <p className="lyrics-paragraph">{section.lines.join('\n')}</p>
sections.push({ tag: `[${tagMatch[1]}]`, lines: [] }); </div>
} else if (sections.length === 0) { ))}
sections.push({ tag: null, lines: [line] }); </div>
} 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>
);
})() : (
<div className="lyrics-placeholder"> <div className="lyrics-placeholder">
{selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')} {selectedType === '배경음악' ? t('soundStudio.lyricsPlaceholderBGM') : t('soundStudio.lyricsPlaceholder')}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -550,7 +550,7 @@
padding: 0 1rem; padding: 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 0.5rem;
scrollbar-color: #046266 transparent; scrollbar-color: #046266 transparent;
} }
@ -810,7 +810,7 @@
/* Asset Page Layout */ /* Asset Page Layout */
.asset-page { .asset-page {
height: 100%; height: 100%;
background-color: #002224; background-color: var(--color-darker);
position: relative; 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 controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT); const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
@ -921,7 +921,7 @@ export async function marketingAnalysis(storeName: string, address: string): Pro
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ store_name: storeName, address }), body: JSON.stringify({ store_name: storeName, address, category }),
signal: controller.signal, signal: controller.signal,
}); });