직접입력시 업종 추가
parent
2f720dfa92
commit
bd92eb2226
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "브랜드 에셋",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@
|
|||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #002224;
|
||||
background-color: var(--color-bg-darker);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -910,7 +910,7 @@
|
|||
}
|
||||
|
||||
.comp2-lyrics-tag {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #379599;
|
||||
letter-spacing: 0.05em;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue