+ {activeRegion !== '특별시 / 광역시' && (
+
+ )}
{cities.map(city => (
diff --git a/src/components/SearchInputForm.tsx b/src/components/SearchInputForm.tsx
new file mode 100644
index 0000000..e616416
--- /dev/null
+++ b/src/components/SearchInputForm.tsx
@@ -0,0 +1,342 @@
+import React, { useState, useRef, useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
+import { useSearchHistory } from './SearchHistory/useSearchHistory';
+import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
+import BusinessNameInputModal from './BusinessNameInputModal';
+
+type SearchType = 'url' | 'name' | 'manual';
+
+export type { SearchType };
+
+const isValidUrl = (string: string): boolean => {
+ try {
+ const url = new URL(string);
+ return url.protocol === 'http:' || url.protocol === 'https:';
+ } catch {
+ return false;
+ }
+};
+
+interface SearchInputFormProps {
+ onAnalyze?: (value: string, type: SearchType) => void;
+ onAutocomplete?: (data: AutocompleteRequest) => void;
+ onManualInput?: (businessName: string, address: string) => void;
+ /** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
+ onManualButtonClick?: () => void;
+ error?: string | null;
+}
+
+const SearchInputForm: React.FC
= ({
+ onAnalyze,
+ onAutocomplete,
+ onManualInput,
+ onManualButtonClick,
+ error: externalError,
+}) => {
+ const { t } = useTranslation();
+ const [inputValue, setInputValue] = useState('');
+ const [searchType, setSearchType] = useState('name');
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const [isManualModalOpen, setIsManualModalOpen] = useState(false);
+ const [localError, setLocalError] = useState('');
+ const [autocompleteResults, setAutocompleteResults] = useState([]);
+ const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
+ const [showAutocomplete, setShowAutocomplete] = useState(false);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+
+ const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
+ const debounceRef = useRef(null);
+ const dropdownRef = useRef(null);
+ const autocompleteRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setIsDropdownOpen(false);
+ }
+ if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
+ setShowAutocomplete(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ const searchTypeOptions = [
+ { value: 'url' as SearchType, label: 'URL' },
+ { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
+ ];
+
+ const getPlaceholder = () =>
+ searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
+
+ const getGuideText = () =>
+ searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
+
+ const handleAutocompleteSearch = useCallback(async (query: string) => {
+ if (!query.trim() || searchType !== 'name') {
+ setAutocompleteResults([]);
+ setShowAutocomplete(false);
+ return;
+ }
+ setIsAutocompleteLoading(true);
+ try {
+ const response = await searchAccommodation(query);
+ setAutocompleteResults(response.items || []);
+ setShowAutocomplete(response.items && response.items.length > 0);
+ } catch {
+ setAutocompleteResults([]);
+ setShowAutocomplete(false);
+ } finally {
+ setIsAutocompleteLoading(false);
+ }
+ }, [searchType]);
+
+ const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
+ setInputValue(item.title.replace(/<[^>]*>/g, ''));
+ setSelectedItem(item);
+ setShowAutocomplete(false);
+ setAutocompleteResults([]);
+ setHighlightedIndex(-1);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (showAutocomplete && autocompleteResults.length > 0) {
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault();
+ setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
+ return;
+ case 'ArrowUp':
+ e.preventDefault();
+ setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
+ return;
+ case 'Enter':
+ e.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
+ handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
+ }
+ return;
+ case 'Escape':
+ e.preventDefault();
+ setShowAutocomplete(false);
+ setHighlightedIndex(-1);
+ return;
+ }
+ }
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ }
+ };
+
+ const handleStart = () => {
+ if (!inputValue.trim()) {
+ setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
+ return;
+ }
+ if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
+ setLocalError(t('landing.hero.errorInvalidUrl'));
+ return;
+ }
+
+ setLocalError('');
+
+ if (searchType === 'name' && selectedItem && onAutocomplete) {
+ onAutocomplete({
+ address: selectedItem.address,
+ roadAddress: selectedItem.roadAddress,
+ title: selectedItem.title,
+ });
+ } else if (onAnalyze) {
+ onAnalyze(inputValue, searchType);
+ }
+ };
+
+ const handleManualButtonClick = () => {
+ if (onManualButtonClick) {
+ onManualButtonClick();
+ } else {
+ setIsManualModalOpen(true);
+ }
+ };
+
+ const error = externalError || localError;
+
+ return (
+
+
+ {/* 드롭다운 */}
+
+
+ {isDropdownOpen && (
+
+ {searchTypeOptions.map((option) => (
+
+ ))}
+
+ )}
+
+
+ {/* 입력 필드 */}
+
+
{
+ let value = e.target.value;
+ if (searchType === 'url') {
+ const urlMatch = value.match(/https?:\/\/\S+/);
+ if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
+ }
+ setInputValue(value);
+ setSelectedItem(null);
+ setHighlightedIndex(-1);
+ hideOnInput(value);
+ if (localError) setLocalError('');
+ if (searchType === 'name') {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
+ }
+ }}
+ onPaste={(e: React.ClipboardEvent
) => {
+ if (searchType !== 'url') return;
+ const pasted = e.clipboardData.getData('text');
+ const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
+ if (urlMatch) {
+ e.preventDefault();
+ setInputValue(urlMatch[0]);
+ if (localError) setLocalError('');
+ }
+ }}
+ onFocus={() => {
+ setIsFocused(true);
+ if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
+ openHistory(inputValue);
+ }}
+ onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
+ onKeyDown={handleKeyDown}
+ placeholder={getPlaceholder()}
+ className={`hero-input ${inputValue ? 'has-value' : ''}`}
+ />
+
+ {/* 최근 검색 히스토리 */}
+ {showHistory && !showAutocomplete && (
+ {
+ closeHistory();
+ if (item.type === 'url') {
+ setSearchType('url');
+ setInputValue(item.value);
+ } else {
+ setSearchType('name');
+ setInputValue(item.value);
+ setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
+ }
+ }}
+ onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
+ className="hero-autocomplete-dropdown"
+ itemClassName="hero-autocomplete-item"
+ titleClassName="hero-autocomplete-title"
+ addressClassName="hero-autocomplete-address"
+ />
+ )}
+
+ {/* 자동완성 결과 */}
+ {showAutocomplete && searchType === 'name' && (
+
+ {isAutocompleteLoading ? (
+
{t('landing.hero.searching')}
+ ) : (
+ autocompleteResults.map((item, index) => (
+
+ ))
+ )}
+
+ )}
+
+
+ {/* 입력 clear 버튼 */}
+ {inputValue && (
+
+ )}
+
+
+
{getGuideText()}
+
+ {error &&
{error}
}
+
+
+
+
+
+ {/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
+ {!onManualButtonClick && isManualModalOpen && (
+
setIsManualModalOpen(false)}
+ onSubmit={(businessName, address) => {
+ setIsManualModalOpen(false);
+ onManualInput?.(businessName, address);
+ }}
+ />
+ )}
+
+ );
+};
+
+export default SearchInputForm;
diff --git a/src/components/Tutorial/TutorialOverlay.tsx b/src/components/Tutorial/TutorialOverlay.tsx
index 7f76c93..1c7d5a3 100644
--- a/src/components/Tutorial/TutorialOverlay.tsx
+++ b/src/components/Tutorial/TutorialOverlay.tsx
@@ -143,7 +143,11 @@ const TutorialOverlay: React.FC = ({
};
const tryBind = (): boolean => {
- const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
+ const els = Array.from(document.querySelectorAll(hint.targetSelector));
+ const el = (els.find(e => {
+ const r = (e as HTMLElement).getBoundingClientRect();
+ return r.width > 0 && r.height > 0;
+ }) ?? els[0]) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;
diff --git a/src/components/Tutorial/tutorialSteps.ts b/src/components/Tutorial/tutorialSteps.ts
index 87f9d4a..0633fd7 100644
--- a/src/components/Tutorial/tutorialSteps.ts
+++ b/src/components/Tutorial/tutorialSteps.ts
@@ -64,6 +64,15 @@ export const tutorialSteps: TutorialStepDef[] = [
noSpotlight: true,
variant: 'bubble',
},
+ {
+ targetSelector: '.hero-manual-button',
+ titleKey: 'tutorial.landing.manual.title',
+ descriptionKey: 'tutorial.landing.manual.desc',
+ position: 'bottom',
+ clickToAdvance: false,
+ noSpotlight: true,
+ variant: 'bubble',
+ },
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',
diff --git a/src/components/Tutorial/useTutorial.ts b/src/components/Tutorial/useTutorial.ts
index 478da87..0b2fdb5 100644
--- a/src/components/Tutorial/useTutorial.ts
+++ b/src/components/Tutorial/useTutorial.ts
@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
@@ -189,6 +189,16 @@ export function useTutorial(): UseTutorialReturn {
return getSeenKeys().includes(key);
}, []);
+ // 영상 보유 사용자 자동 off 이벤트 수신
+ useEffect(() => {
+ const handler = () => {
+ setIsActive(false);
+ setIsEnabled(false);
+ };
+ window.addEventListener('ado2-tutorial-auto-disable', handler);
+ return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
+ }, []);
+
return {
isActive,
isEnabled,
diff --git a/src/locales/en.json b/src/locales/en.json
index 6676ca5..a6393c5 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -30,6 +30,7 @@
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
+ "manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
"asset": {
@@ -174,8 +175,12 @@
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
+ "manualLabelRegion": "Region",
+ "manualLabelDetail": "Detail Address",
"manualPlaceholderName": "Enter the business name",
- "manualPlaceholderAddress": "Enter the address"
+ "manualPlaceholderAddress": "Enter the address",
+ "manualPlaceholderRegion": "Select a region",
+ "manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"welcome": {
"title": "Welcome to ADO2.AI",
@@ -208,11 +213,19 @@
"testDataLoading": "Loading...",
"testData": "Test Data",
"searchTypeManual": "Manual Input",
+ "errorSelectFromList": "Please select a business from the list.",
+ "errorUrlRequired": "Please enter a URL.",
+ "errorNameRequired": "Please enter a business name.",
+ "errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
+ "manualLabelRegion": "Region",
+ "manualLabelDetail": "Detail Address",
"manualPlaceholderName": "Enter the business name",
- "manualPlaceholderAddress": "Enter the address"
+ "manualPlaceholderAddress": "Enter the address",
+ "manualPlaceholderRegion": "Select a region",
+ "manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"assetManagement": {
"title": "Brand Assets",
@@ -434,7 +447,7 @@
"title": "My Info",
"tabBasic": "Basic Info",
"tabPayment": "Payment Info",
- "tabBusiness": "My Business & Social Channel Management",
+ "tabBusiness": "Social Channel Management",
"basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index 1f20d08..26a79ed 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -30,6 +30,7 @@
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
+ "manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"asset": {
@@ -55,7 +56,7 @@
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
- "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
+ "connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
@@ -174,8 +175,12 @@
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
+ "manualLabelRegion": "지역",
+ "manualLabelDetail": "상세 주소",
"manualPlaceholderName": "업체명을 입력하세요.",
- "manualPlaceholderAddress": "주소를 입력하세요."
+ "manualPlaceholderAddress": "주소를 입력하세요.",
+ "manualPlaceholderRegion": "지역을 선택하세요.",
+ "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
@@ -208,11 +213,19 @@
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
+ "errorSelectFromList": "목록에서 업체를 선택해 주세요.",
+ "errorUrlRequired": "URL을 입력해주세요.",
+ "errorNameRequired": "업체명을 입력해주세요.",
+ "errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
+ "manualLabelRegion": "지역",
+ "manualLabelDetail": "상세 주소",
"manualPlaceholderName": "업체명을 입력하세요.",
- "manualPlaceholderAddress": "주소를 입력하세요."
+ "manualPlaceholderAddress": "주소를 입력하세요.",
+ "manualPlaceholderRegion": "지역을 선택하세요.",
+ "manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"assetManagement": {
"title": "브랜드 에셋",
@@ -434,7 +447,7 @@
"title": "내 정보",
"tabBasic": "기본 정보",
"tabPayment": "결제 정보",
- "tabBusiness": "내 비즈니스 & 소셜 채널 관리",
+ "tabBusiness": "소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스",
diff --git a/src/pages/Analysis/LoadingSection.tsx b/src/pages/Analysis/LoadingSection.tsx
index ab32e8d..013b903 100755
--- a/src/pages/Analysis/LoadingSection.tsx
+++ b/src/pages/Analysis/LoadingSection.tsx
@@ -50,7 +50,7 @@ const LoadingSection: React.FC = ({ onComplete, isComplete
{/* Logo */}
-

+
{/* Loading Spinner and Text */}
diff --git a/src/pages/Dashboard/MyInfoContent.tsx b/src/pages/Dashboard/MyInfoContent.tsx
index 102e8a3..f2f4151 100644
--- a/src/pages/Dashboard/MyInfoContent.tsx
+++ b/src/pages/Dashboard/MyInfoContent.tsx
@@ -113,7 +113,7 @@ const MyInfoContent: React.FC
= ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [
- { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
+ // { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
];
@@ -205,11 +205,11 @@ const MyInfoContent: React.FC = ({ initialTab }) => {
{/* 탭 컨텐츠 */}
- {activeTab === 'basic' && (
+ {/* {activeTab === 'basic' && (
{t('myInfo.basicPlaceholder')}
- )}
+ )} */}
{activeTab === 'payment' && (
@@ -240,7 +240,7 @@ const MyInfoContent: React.FC
= ({ initialTab }) => {
{activeTab === 'business' && (
<>
- {/* 내 비즈니스 섹션 */}
+ {/* 내 비즈니스 섹션
{t('myInfo.myBusiness')}
@@ -261,7 +261,7 @@ const MyInfoContent: React.FC = ({ initialTab }) => {
-
+
*/}
{/* 소셜 채널 섹션 */}
diff --git a/src/pages/Dashboard/SoundStudioContent.tsx b/src/pages/Dashboard/SoundStudioContent.tsx
index b4aa424..55e5319 100755
--- a/src/pages/Dashboard/SoundStudioContent.tsx
+++ b/src/pages/Dashboard/SoundStudioContent.tsx
@@ -26,6 +26,19 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
const MAX_RETRY_COUNT = 3;
+const sanitizeError = (message: string, fallback: string): string => {
+ if (
+ message.includes('Traceback') ||
+ message.includes('File "/') ||
+ message.includes('httpx.') ||
+ message.includes('httpcore.') ||
+ message.length > 200
+ ) {
+ return fallback;
+ }
+ return message;
+};
+
const LANGUAGE_FLAGS: Record
= {
'한국어': '🇰🇷',
'English': '🇺🇸',
@@ -131,7 +144,10 @@ const SoundStudioContent: React.FC = ({
}
} else {
setStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
+ setErrorMessage(sanitizeError(
+ error instanceof Error ? error.message : '',
+ t('soundStudio.musicGenerationError')
+ ));
setRetryCount(0);
}
}
@@ -168,7 +184,10 @@ const SoundStudioContent: React.FC = ({
} catch (error) {
console.error('Song regeneration failed:', error);
setStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
+ setErrorMessage(sanitizeError(
+ error instanceof Error ? error.message : '',
+ t('soundStudio.songRegenerationError')
+ ));
setRetryCount(0);
}
};
@@ -362,7 +381,10 @@ const SoundStudioContent: React.FC = ({
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
- setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
+ setErrorMessage(sanitizeError(
+ error instanceof Error ? error.message : '',
+ t('soundStudio.musicGenerationError')
+ ));
setRetryCount(0);
}
};
@@ -496,8 +518,8 @@ const SoundStudioContent: React.FC = ({
{['한국어', 'English', '中文'].map((lang) => (