Compare commits
4 Commits
feature-AD
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
cd7b1bf9f3 | |
|
|
b18dd7aa4d | |
|
|
7f1e2b83a9 | |
|
|
1d855fd14c |
190
index.css
190
index.css
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
27
src/App.tsx
27
src/App.tsx
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "로그인 처리 중...",
|
||||||
|
|
|
||||||
|
|
@ -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'}`}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 처리
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue