Compare commits

...

4 Commits

15 changed files with 491 additions and 36 deletions

190
index.css
View File

@ -931,6 +931,56 @@
font-weight: 700; font-weight: 700;
} }
/* 모바일 전용 사이드바 튜토리얼 토글 */
.sidebar-tutorial-btn {
display: none;
}
@media (max-width: 767px) {
.sidebar-tutorial-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: var(--radius-lg);
color: var(--color-text-gray-400);
border: none;
background: none;
cursor: pointer;
font-size: var(--text-sm);
font-weight: 500;
transition: color var(--transition-normal), background-color var(--transition-normal);
}
.sidebar-tutorial-btn:hover {
color: var(--color-text-white);
background-color: rgba(255, 255, 255, 0.05);
}
.sidebar-tutorial-btn.active {
color: var(--color-mint);
}
.sidebar-tutorial-label {
text-align: left;
}
.sidebar-tutorial-badge {
font-size: 11px;
font-weight: 700;
}
.sidebar-tutorial-badge.on {
color: var(--color-mint);
}
.sidebar-tutorial-badge.off {
color: var(--color-text-gray-400);
}
}
/* Mobile Menu Button */ /* Mobile Menu Button */
.mobile-menu-btn { .mobile-menu-btn {
position: fixed; position: fixed;
@ -12301,6 +12351,12 @@
border-color: rgba(166, 255, 234, 0.4); border-color: rgba(166, 255, 234, 0.4);
} }
@media (max-width: 767px) {
.tutorial-toggle-fab {
display: none;
}
}
.tutorial-toggle-badge { .tutorial-toggle-badge {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@ -12318,3 +12374,137 @@
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
} }
/* BusinessNameInputModal */
.manual-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.manual-modal {
background: #1a2a2b;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
width: 375px;
max-width: 92vw;
display: flex;
flex-direction: column;
}
.manual-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.manual-modal-title {
font-size: 15px;
font-weight: 600;
color: #fff;
}
.manual-modal-close {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 16px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.manual-modal-close:hover {
color: #fff;
}
.manual-modal-body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 20px 24px;
}
.manual-modal-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.manual-modal-label {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.manual-modal-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(155, 202, 204, 0.25);
background: rgba(255, 255, 255, 0.06);
color: #fff;
font-size: 14px;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.manual-modal-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.manual-modal-input:focus {
border-color: #9BCACC;
}
.manual-modal-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.manual-modal-cancel {
flex: 1;
padding: 11px 0;
border-radius: 8px;
border: 1px solid rgba(155, 202, 204, 0.3);
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.manual-modal-cancel:hover {
background: rgba(155, 202, 204, 0.08);
}
.manual-modal-submit {
flex: 2;
padding: 11px 0;
border-radius: 8px;
border: none;
background: #9BCACC;
color: #1a2a2b;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.manual-modal-submit:hover:not(:disabled) {
background: #b0d8da;
}
.manual-modal-submit:disabled {
opacity: 0.35;
cursor: not-allowed;
}

View File

@ -41,7 +41,7 @@
</ul> </ul>
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2> <h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API(<code>youtube.readonly</code>, <code>youtube.upload</code>) 및 YouTube Analytics API(<code>yt-analytics.readonly</code>)를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p> <p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 특히 YouTube 서비스 연동을 위해 YouTube Data API 및 YouTube Analytics API를 사용하며, 이를 통해 수집·처리되는 데이터는 <a href="/privacy.html">개인정보처리방침</a>에 따라 관리됩니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
<p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a><a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p> <p>YouTube API 서비스 이용과 관련하여 <a href="https://www.youtube.com/t/terms" target="_blank">YouTube 이용약관</a><a href="https://policies.google.com/privacy" target="_blank">Google 개인정보처리방침</a>이 함께 적용됩니다.</p>
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2> <h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>

View File

@ -15,7 +15,7 @@ import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage'; import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage'; import VideoDetailPage from './components/VideoDetailPage';
import LoginPromptModal from './components/LoginPromptModal'; import LoginPromptModal from './components/LoginPromptModal';
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api'; import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory'; import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api'; import { CrawlingResponse } from './types/api';
@ -314,6 +314,30 @@ const App: React.FC = () => {
} }
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (!validateCrawlingResponse(data)) {
throw new Error(t('app.autocompleteError'));
}
setAnalysisData(data);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis failed:', err);
setError(t('app.autocompleteError'));
setViewMode('landing');
}
};
// 테스트 데이터로 브랜드 분석 페이지 이동 // 테스트 데이터로 브랜드 분석 페이지 이동
const handleTestData = (data: CrawlingResponse) => { const handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true }; const tagged = { ...data, _isTestData: true };
@ -462,6 +486,7 @@ const App: React.FC = () => {
<HeroSection <HeroSection
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData} onTestData={handleTestData}
onNext={() => scrollToSection(1)} onNext={() => scrollToSection(1)}
error={error} error={error}

View File

@ -0,0 +1,95 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
interface BusinessNameInputModalProps {
onClose: () => void;
onSubmit: (businessName: string, address: string) => void;
}
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
const { t } = useTranslation();
const [businessName, setBusinessName] = useState('');
const [address, setAddress] = useState('');
useEffect(() => {
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = '';
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const isValid = businessName.trim().length > 0 && address.trim().length > 0;
const handleSubmit = () => {
if (!isValid) return;
onSubmit(businessName.trim(), address.trim());
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && isValid) handleSubmit();
};
return (
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
<span className="manual-modal-title">{t('landing.hero.manualModalTitle')}</span>
<button className="manual-modal-close" onClick={onClose}></button>
</div>
<div className="manual-modal-body">
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelName')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderName')}
value={businessName}
onChange={e => setBusinessName(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={50}
autoFocus
/>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderAddress')}
value={address}
onChange={e => setAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
</div>
<div className="manual-modal-actions">
<button type="button" className="manual-modal-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-modal-submit"
onClick={handleSubmit}
disabled={!isValid}
>
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>
</div>
</div>
);
};
export default BusinessNameInputModal;

View File

@ -71,17 +71,25 @@ const LoginPromptModal: React.FC<LoginPromptModalProps> = ({ onClose }) => {
<button <button
onClick={handleLogin} onClick={handleLogin}
style={{ style={{
width: '100%', width: '50%',
margin: '0 auto',
padding: '14px', padding: '14px',
background: '#1A8F93', background: '#FEE500',
color: '#FFFFFF', color: '#3C1E1E',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius: '8px',
fontSize: '15px', fontSize: '15px',
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}} }}
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M10 2C5.582 2 2 4.91 2 8.5c0 2.26 1.37 4.25 3.44 5.44L4.6 17.1a.3.3 0 0 0 .44.33l4.03-2.67c.3.03.62.04.93.04 4.418 0 8-2.91 8-6.5S14.418 2 10 2z" fill="#3C1E1E"/>
</svg>
{t('loginPrompt.loginBtn')} {t('loginPrompt.loginBtn')}
</button> </button>
</div> </div>

View File

@ -38,9 +38,12 @@ interface SidebarProps {
userInfo?: UserMeResponse | null; userInfo?: UserMeResponse | null;
onLogout?: () => void; onLogout?: () => void;
credits?: number | null; credits?: number | null;
tutorialAvailable?: boolean;
tutorialEnabled?: boolean;
onToggleTutorial?: () => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits }) => { const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits, tutorialAvailable, tutorialEnabled, onToggleTutorial }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false);
@ -159,6 +162,24 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
{/* 모바일 전용 튜토리얼 토글 */}
{tutorialAvailable && (
<button
className={`sidebar-tutorial-btn ${tutorialEnabled ? 'active' : ''}`}
onClick={() => onToggleTutorial?.()}
title={tutorialEnabled ? t('sidebar.tutorialOff') : t('sidebar.tutorialOn')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 8v4l3 3"/>
</svg>
<span className="sidebar-tutorial-label">{t('sidebar.tutorial')}</span>
<span className={`sidebar-tutorial-badge ${tutorialEnabled ? 'on' : 'off'}`}>
{tutorialEnabled ? 'ON' : 'OFF'}
</span>
</button>
)}
<div className="sidebar-language-switch"> <div className="sidebar-language-switch">
<LanguageSwitch isCollapsed={isCollapsed} /> <LanguageSwitch isCollapsed={isCollapsed} />
</div> </div>

View File

@ -294,14 +294,14 @@ export const tutorialSteps: TutorialStepDef[] = [
targetSelector: '.social-posting-form', targetSelector: '.social-posting-form',
titleKey: 'tutorial.upload.required.title', titleKey: 'tutorial.upload.required.title',
descriptionKey: 'tutorial.upload.required.desc', descriptionKey: 'tutorial.upload.required.desc',
position: 'left', position: 'top',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {
targetSelector: '.social-posting-radio-group', targetSelector: '.social-posting-radio-group',
titleKey: 'tutorial.upload.schedule.title', titleKey: 'tutorial.upload.schedule.title',
descriptionKey: 'tutorial.upload.schedule.desc', descriptionKey: 'tutorial.upload.schedule.desc',
position: 'left', position: 'top',
clickToAdvance: false, clickToAdvance: false,
}, },
{ {

View File

@ -299,8 +299,9 @@ const VideoDetailContent: React.FC<VideoDetailContentProps> = ({ videoId, isModa
<div className="video-detail-share-menu"> <div className="video-detail-share-menu">
{/* 카카오톡 */} {/* 카카오톡 */}
<button className="video-detail-share-item" onClick={handleKakaoShare}> <button className="video-detail-share-item" onClick={handleKakaoShare}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="#FEE500"> <svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3C6.477 3 2 6.477 2 10.8c0 2.736 1.582 5.14 3.978 6.592-.175.598-.63 2.178-.723 2.514-.113.412.151.406.318.295.13-.087 2.07-1.403 2.909-1.97.487.068.986.104 1.518.104 5.523 0 10-3.477 10-7.8S17.523 3 12 3z"/> <rect width="20" height="20" rx="4" fill="#FEE500"/>
<path fillRule="evenodd" clipRule="evenodd" d="M10 3.5C6.134 3.5 3 6.01 3 9.1c0 1.98 1.2 3.72 3.01 4.76l-.74 2.75a.19.19 0 0 0 .28.21l3.37-2.23c.34.04.69.06 1.06.06 3.866 0 7-2.51 7-5.6S13.866 3.5 10 3.5z" fill="#3C1E1E"/>
</svg> </svg>
</button> </button>

View File

@ -95,8 +95,8 @@
"footer": { "footer": {
"company":"O2O Inc.", "company":"O2O Inc.",
"businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin", "businessNumber": "Business Registration No. : 620-87-00810 | CEO : Ahn Sungmin",
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea", "headquarters": "HQ : Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
"researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea", "researchCenter": "R&D : Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
"phone": "Tel : 070-4260-8310 | 010-2755-6463", "phone": "Tel : 070-4260-8310 | 010-2755-6463",
"email": "Email : o2oteam@o2o.kr", "email": "Email : o2oteam@o2o.kr",
"privacyPolicy": "Privacy Policy", "privacyPolicy": "Privacy Policy",
@ -169,7 +169,13 @@
"testDataLoading": "Loading...", "testDataLoading": "Loading...",
"testData": "Test Data", "testData": "Test Data",
"testDataLoadFailed": "Failed to load test data.", "testDataLoadFailed": "Failed to load test data.",
"searching": "Searching..." "searching": "Searching...",
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
}, },
"welcome": { "welcome": {
"title": "Welcome to ADO2.AI", "title": "Welcome to ADO2.AI",
@ -200,7 +206,13 @@
"searchButton": "Search", "searchButton": "Search",
"searching": "Searching...", "searching": "Searching...",
"testDataLoading": "Loading...", "testDataLoading": "Loading...",
"testData": "Test Data" "testData": "Test Data",
"searchTypeManual": "Manual Input",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
}, },
"assetManagement": { "assetManagement": {
"title": "Brand Assets", "title": "Brand Assets",

View File

@ -95,8 +95,8 @@
"footer": { "footer": {
"company":"㈜에이아이오투오", "company":"㈜에이아이오투오",
"businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민", "businessNumber": "사업자 등록번호 : 620-87-00810 | 대표 : 안성민",
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호", "headquarters": "본사 : 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)", "researchCenter": "연구소 : 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
"phone": "전화 : 070-4260-8310 | 010-2755-6463", "phone": "전화 : 070-4260-8310 | 010-2755-6463",
"email": "이메일 : o2oteam@o2o.kr", "email": "이메일 : o2oteam@o2o.kr",
"privacyPolicy": "개인정보처리방침", "privacyPolicy": "개인정보처리방침",
@ -169,7 +169,13 @@
"testDataLoading": "로딩 중...", "testDataLoading": "로딩 중...",
"testData": "테스트 데이터", "testData": "테스트 데이터",
"testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.", "testDataLoadFailed": "테스트 데이터를 불러오는데 실패했습니다.",
"searching": "검색 중..." "searching": "검색 중...",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
}, },
"welcome": { "welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.", "title": "ADO2.AI에 오신 것을 환영합니다.",
@ -200,7 +206,13 @@
"searchButton": "검색하기", "searchButton": "검색하기",
"searching": "검색 중...", "searching": "검색 중...",
"testDataLoading": "로딩 중...", "testDataLoading": "로딩 중...",
"testData": "테스트 데이터" "testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
}, },
"assetManagement": { "assetManagement": {
"title": "브랜드 에셋", "title": "브랜드 에셋",
@ -554,8 +566,8 @@
"failedCount": "실패 {{count}}" "failedCount": "실패 {{count}}"
}, },
"loginPrompt": { "loginPrompt": {
"title": "로그인이 필요합니다", "title": "로그인이 필요합니다.",
"loginBtn": "카카오로 로그인" "loginBtn": "로그인"
}, },
"app": { "app": {
"loginProcessing": "로그인 처리 중...", "loginProcessing": "로그인 처리 중...",

View File

@ -15,7 +15,7 @@ import ContentCalendarContent from './ContentCalendarContent';
import LoadingSection from '../Analysis/LoadingSection'; import LoadingSection from '../Analysis/LoadingSection';
import AnalysisResultSection from '../Analysis/AnalysisResultSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection';
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api'; import { crawlUrl, autocomplete, marketingAnalysis, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
import { useTutorial } from '../../components/Tutorial/useTutorial'; import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
@ -261,6 +261,33 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
} }
}; };
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
goToWizardStep(-1);
setIsAnalysisComplete(false);
setAnalysisError(null);
try {
const data = await marketingAnalysis(businessName, address);
if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || businessName;
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));
saveSearchHistory({ type: 'name', value: businessName, address, roadAddress: address });
setIsAnalysisComplete(true);
} catch (err) {
console.error('Marketing analysis error:', err);
setAnalysisError(t('app.autocompleteError'));
goToWizardStep(-2);
}
};
// 테스트용 랜덤 m_id 생성 (99 ~ 300) // 테스트용 랜덤 m_id 생성 (99 ~ 300)
const generateRandomMId = (): number => { const generateRandomMId = (): number => {
return Math.floor(Math.random() * (300 - 99 + 1)) + 99; return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
@ -411,6 +438,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<UrlInputContent <UrlInputContent
onAnalyze={handleStartAnalysis} onAnalyze={handleStartAnalysis}
onAutocomplete={handleAutocomplete} onAutocomplete={handleAutocomplete}
onManualInput={handleManualInput}
onTestData={handleTestData} onTestData={handleTestData}
error={analysisError} error={analysisError}
/> />
@ -604,7 +632,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return ( return (
<div className="analysis-page-wrapper"> <div className="analysis-page-wrapper">
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)} )}
<main className="analysis-page-main"> <main className="analysis-page-main">
{renderContent()} {renderContent()}
@ -617,7 +645,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return ( return (
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}> <div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
{showSidebar && ( {showSidebar && (
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} /> <Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} tutorialAvailable={!!getCurrentTutorialKey()} tutorialEnabled={tutorial.isEnabled} onToggleTutorial={() => tutorial.toggleTutorial(getCurrentTutorialKey())} />
)} )}
{tutorialUI} {tutorialUI}
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}> <div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>

View File

@ -4,8 +4,9 @@ import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } fro
import { CrawlingResponse } from '../../types/api'; import { CrawlingResponse } from '../../types/api';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name'; type SearchType = 'url' | 'name' | 'manual';
// 환경변수에서 테스트 모드 확인 // 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -13,11 +14,12 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
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;
onTestData?: (data: CrawlingResponse) => void; onTestData?: (data: CrawlingResponse) => void;
error: string | null; error: string | null;
} }
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => { const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name'); const [searchType, setSearchType] = useState<SearchType>('name');
@ -27,7 +29,8 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const [showAutocomplete, setShowAutocomplete] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null); const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false); const [isLoadingTest, setIsLoadingTest] = useState(false);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType); const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => { const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
closeHistory(); closeHistory();
@ -67,6 +70,7 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
const searchTypeOptions = [ const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' }, { value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') }, { value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') },
]; ];
const getPlaceholder = () => { const getPlaceholder = () => {
@ -215,8 +219,13 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
type="button" type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`} className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => { onClick={() => {
setSearchType(option.value); if (option.value === 'manual') {
setIsDropdownOpen(false); setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}} }}
> >
{option.label} {option.label}
@ -329,6 +338,13 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')} {isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button> </button>
)} )}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div> </div>
); );
}; };

View File

@ -11,9 +11,9 @@ const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// YouTube Shorts 영상 ID들 // YouTube Shorts 영상 ID들
const videos = [ const videos = [
{ id: 1, videoId: 'trnN0SQBTiI' }, { id: 1, videoId: 'M3iuPZ59X1I' },
{ id: 2, videoId: '96HO497HsQI' }, { id: 2, videoId: 'JxWQxELDHSs' },
{ id: 3, videoId: 'XziImxVICIk' }, { id: 3, videoId: 'c2ZdwhaB7S4' },
]; ];
return ( return (

View File

@ -8,8 +8,9 @@ import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay'; import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory'; import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown'; import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name'; type SearchType = 'url' | 'name' | 'manual';
// 환경변수에서 테스트 모드 확인 // 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -17,6 +18,7 @@ const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
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;
onTestData?: (data: CrawlingResponse) => void; onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void; onNext?: () => void;
error?: string | null; error?: string | null;
@ -62,11 +64,12 @@ const orbConfigs: OrbConfig[] = [
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 }, { id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
]; ];
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name'); const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState(''); const [localError, setLocalError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false); const [isLoadingTest, setIsLoadingTest] = useState(false);
@ -86,7 +89,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
setIsLoadingTest(false); setIsLoadingTest(false);
} }
}; };
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType); const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]); const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
@ -113,6 +116,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
const searchTypeOptions = [ const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' }, { value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') }, { value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
]; ];
// 드롭다운 외부 클릭 감지 // 드롭다운 외부 클릭 감지
@ -377,8 +381,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
type="button" type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`} className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => { onClick={() => {
setSearchType(option.value); if (option.value === 'manual') {
setIsDropdownOpen(false); setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}} }}
> >
{option.label} {option.label}
@ -555,6 +564,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
groupProgress={tutorial.groupProgress} groupProgress={tutorial.groupProgress}
/> />
)} )}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div> </div>
); );
}; };

View File

@ -900,6 +900,37 @@ export async function autocomplete(request: AutocompleteRequest): Promise<Crawli
} }
} }
// 업체명·주소 직접 입력으로 마케팅 분석
export async function marketingAnalysis(storeName: string, address: string): Promise<CrawlingResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CRAWL_TIMEOUT);
try {
const response = await authenticatedFetch(`${API_URL}/marketing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ store_name: storeName, address }),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('요청 시간이 초과되었습니다. 다시 시도해주세요.');
}
throw error;
}
}
// ============================================ // ============================================
// Social OAuth TOKEN_EXPIRED 처리 // Social OAuth TOKEN_EXPIRED 처리
// ============================================ // ============================================