460 lines
16 KiB
TypeScript
Executable File
460 lines
16 KiB
TypeScript
Executable File
|
|
import React, { useRef, useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import Header from './components/Header';
|
|
import HeroSection from './pages/Landing/HeroSection';
|
|
import WelcomeSection from './pages/Landing/WelcomeSection';
|
|
import DisplaySection from './pages/Landing/DisplaySection';
|
|
import LoadingSection from './pages/Analysis/LoadingSection';
|
|
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
|
import LoginSection from './pages/Login/LoginSection';
|
|
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
|
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
|
import SocialConnectError from './pages/Social/SocialConnectError';
|
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
|
import { crawlUrl, autocomplete, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
|
|
import { CrawlingResponse } from './types/api';
|
|
|
|
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
|
|
|
const VIEW_MODE_KEY = 'castad_view_mode';
|
|
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
|
const SESSION_KEY = 'castad_session_active';
|
|
|
|
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
|
const initializeOnNewSession = () => {
|
|
const isExistingSession = sessionStorage.getItem(SESSION_KEY);
|
|
|
|
if (!isExistingSession) {
|
|
// 새 세션이면 localStorage 정리하고 세션 표시
|
|
localStorage.removeItem(VIEW_MODE_KEY);
|
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
|
localStorage.removeItem('castad_wizard_step');
|
|
localStorage.removeItem('castad_active_item');
|
|
localStorage.removeItem('castad_song_task_id');
|
|
localStorage.removeItem('castad_image_task_id');
|
|
localStorage.removeItem('castad_song_generation');
|
|
localStorage.removeItem('castad_video_generation');
|
|
localStorage.removeItem('castad_video_ratio');
|
|
sessionStorage.setItem(SESSION_KEY, 'true');
|
|
}
|
|
};
|
|
|
|
// 앱 시작 시 세션 체크
|
|
initializeOnNewSession();
|
|
|
|
const App: React.FC = () => {
|
|
const { t, i18n } = useTranslation();
|
|
const containerRef = useRef<HTMLElement>(null);
|
|
|
|
// localStorage에서 저장된 상태 복원 (새 세션이면 이미 초기화됨)
|
|
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
|
|
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
|
|
// 저장된 분석 데이터 파싱 및 유효성 검사
|
|
const parseSavedAnalysisData = (): CrawlingResponse | null => {
|
|
if (!savedAnalysisData) return null;
|
|
try {
|
|
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
|
|
// 기본값 보장
|
|
if (data.marketing_analysis) {
|
|
data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
|
|
location_feature_analysis: '',
|
|
concept_scalability: ''
|
|
};
|
|
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
|
|
category_definition: '',
|
|
core_value: ''
|
|
};
|
|
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
|
|
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
|
|
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
|
|
}
|
|
if (data.processed_info) {
|
|
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
|
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 || [];
|
|
return data;
|
|
} catch {
|
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
|
|
const getInitialViewMode = (): ViewMode => {
|
|
if (savedViewMode === 'generation_flow') return 'generation_flow';
|
|
if (isLoggedIn()) return 'generation_flow';
|
|
return 'landing';
|
|
};
|
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
|
|
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
|
|
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
|
|
parseSavedAnalysisData()
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [scrollProgress, setScrollProgress] = useState(0);
|
|
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
|
|
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
|
useEffect(() => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const accessToken = urlParams.get('access_token');
|
|
const refreshToken = urlParams.get('refresh_token');
|
|
const code = urlParams.get('code');
|
|
const currentPath = window.location.pathname;
|
|
|
|
// Social OAuth 콜백 경로는 여기서 처리하지 않음 (백엔드에서 처리)
|
|
const isSocialOAuthCallback = currentPath.includes('/social/oauth/') ||
|
|
currentPath.includes('/social/connect/');
|
|
|
|
// 백엔드에서 토큰을 URL 파라미터로 전달한 경우
|
|
if (accessToken && refreshToken && !isProcessingCallback) {
|
|
setIsProcessingCallback(true);
|
|
handleTokenCallback(accessToken, refreshToken);
|
|
}
|
|
// 기존 code 방식 (Redirect URI가 프론트엔드인 경우) - Social OAuth는 제외
|
|
else if (code && !isProcessingCallback && !isSocialOAuthCallback) {
|
|
setIsProcessingCallback(true);
|
|
handleKakaoCallback(code);
|
|
}
|
|
}, []);
|
|
|
|
// 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리
|
|
const handleTokenCallback = (accessToken: string, refreshToken: string) => {
|
|
try {
|
|
// 토큰 저장
|
|
saveTokens(accessToken, refreshToken);
|
|
|
|
// URL에서 토큰 파라미터 제거
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('access_token');
|
|
url.searchParams.delete('refresh_token');
|
|
window.history.replaceState({}, document.title, url.pathname);
|
|
|
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
if (savedData) {
|
|
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
|
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
|
localStorage.removeItem('castad_wizard_step');
|
|
localStorage.removeItem('castad_active_item');
|
|
}
|
|
setInitialTab('새 프로젝트 만들기');
|
|
setViewMode('generation_flow');
|
|
} catch (err) {
|
|
console.error('Token callback failed:', err);
|
|
alert(t('app.loginFailed'));
|
|
} finally {
|
|
setIsProcessingCallback(false);
|
|
}
|
|
};
|
|
|
|
const handleKakaoCallback = async (code: string) => {
|
|
try {
|
|
const response = await kakaoCallback(code);
|
|
|
|
// 로그인 성공 - 서버에서 받은 redirect_url로 이동
|
|
window.location.href = response.redirect_url;
|
|
} catch (err) {
|
|
console.error('Kakao callback failed:', err);
|
|
alert(t('app.kakaoLoginFailed'));
|
|
|
|
// URL에서 code 파라미터 제거
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('code');
|
|
window.history.replaceState({}, document.title, url.pathname);
|
|
} finally {
|
|
setIsProcessingCallback(false);
|
|
}
|
|
};
|
|
|
|
// viewMode 변경 시 localStorage에 저장
|
|
useEffect(() => {
|
|
localStorage.setItem(VIEW_MODE_KEY, viewMode);
|
|
}, [viewMode]);
|
|
|
|
// 스크롤 이벤트 핸들러 - 첫 번째 섹션에서 두 번째 섹션으로 넘어갈 때 0~1 값 계산
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container || viewMode !== 'landing') return;
|
|
|
|
const handleScroll = () => {
|
|
const scrollTop = container.scrollTop;
|
|
const sectionHeight = container.clientHeight;
|
|
// 첫 번째 섹션 스크롤 진행률 (0 ~ 1)
|
|
const progress = Math.min(1, Math.max(0, scrollTop / sectionHeight));
|
|
setScrollProgress(progress);
|
|
};
|
|
|
|
container.addEventListener('scroll', handleScroll);
|
|
return () => container.removeEventListener('scroll', handleScroll);
|
|
}, [viewMode]);
|
|
|
|
const scrollToSection = (index: number) => {
|
|
if (containerRef.current) {
|
|
const h = containerRef.current.clientHeight;
|
|
containerRef.current.scrollTo({
|
|
top: h * index,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
};
|
|
|
|
// 크롤링 응답 유효성 검사
|
|
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
|
|
// 필수 필드 존재 여부 확인
|
|
if (!data) return false;
|
|
if (!data.processed_info) return false;
|
|
if (!data.marketing_analysis) return false;
|
|
|
|
// marketing_analysis 내부 필드 기본값 보장
|
|
if (!data.marketing_analysis.brand_identity) {
|
|
data.marketing_analysis.brand_identity = {
|
|
location_feature_analysis: '',
|
|
concept_scalability: ''
|
|
};
|
|
}
|
|
if (!data.marketing_analysis.market_positioning) {
|
|
data.marketing_analysis.market_positioning = {
|
|
category_definition: '',
|
|
core_value: ''
|
|
};
|
|
}
|
|
if (!data.marketing_analysis.target_persona) {
|
|
data.marketing_analysis.target_persona = [];
|
|
}
|
|
if (!data.marketing_analysis.selling_points) {
|
|
data.marketing_analysis.selling_points = [];
|
|
}
|
|
if (!data.marketing_analysis.target_keywords) {
|
|
data.marketing_analysis.target_keywords = [];
|
|
}
|
|
|
|
// processed_info 내부 필드 기본값 보장
|
|
if (!data.processed_info.customer_name) {
|
|
data.processed_info.customer_name = '알 수 없음';
|
|
}
|
|
if (!data.processed_info.region) {
|
|
data.processed_info.region = '';
|
|
}
|
|
if (!data.processed_info.detail_region_info) {
|
|
data.processed_info.detail_region_info = '';
|
|
}
|
|
|
|
// image_list 기본값 보장
|
|
if (!data.image_list) {
|
|
data.image_list = [];
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleStartAnalysis = async (url: string) => {
|
|
if (!url.trim()) return;
|
|
|
|
setViewMode('loading');
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await crawlUrl(url);
|
|
|
|
// 응답 유효성 검사
|
|
if (!validateCrawlingResponse(data)) {
|
|
throw new Error(t('app.invalidUrl'));
|
|
}
|
|
|
|
setAnalysisData(data);
|
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
|
setViewMode('analysis');
|
|
} catch (err) {
|
|
console.error('Crawling failed:', err);
|
|
const errorMessage = err instanceof Error ? err.message : t('app.analysisError');
|
|
setError(errorMessage);
|
|
setViewMode('landing');
|
|
}
|
|
};
|
|
|
|
// 업체명 자동완성으로 분석 시작
|
|
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
|
setViewMode('loading');
|
|
setError(null);
|
|
|
|
try {
|
|
const data = await autocomplete(request);
|
|
|
|
// 응답 유효성 검사
|
|
if (!validateCrawlingResponse(data)) {
|
|
throw new Error(t('app.autocompleteError'));
|
|
}
|
|
|
|
setAnalysisData(data);
|
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
|
setViewMode('analysis');
|
|
} catch (err) {
|
|
console.error('Autocomplete failed:', err);
|
|
const errorMessage = err instanceof Error ? err.message : t('app.autocompleteGeneralError');
|
|
setError(errorMessage);
|
|
setViewMode('landing');
|
|
}
|
|
};
|
|
|
|
// 테스트 데이터로 브랜드 분석 페이지 이동
|
|
const handleTestData = (data: CrawlingResponse) => {
|
|
const tagged = { ...data, _isTestData: true };
|
|
setAnalysisData(tagged);
|
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
setViewMode('analysis');
|
|
};
|
|
|
|
// 언어 변경 시 테스트 데이터 다시 로드
|
|
useEffect(() => {
|
|
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
if (!saved) return;
|
|
try {
|
|
const parsed = JSON.parse(saved);
|
|
if (!parsed._isTestData) return;
|
|
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
fetch(jsonFile)
|
|
.then(res => res.json())
|
|
.then((data: CrawlingResponse) => {
|
|
const tagged = { ...data, _isTestData: true };
|
|
setAnalysisData(tagged);
|
|
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
})
|
|
.catch(err => console.error('Failed to reload test data:', err));
|
|
} catch { /* ignore */ }
|
|
}, [i18n.language]);
|
|
|
|
const handleToLogin = async () => {
|
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
|
if (isLoggedIn()) {
|
|
// 분석 데이터가 있으면 이전 wizard step 초기화 (에셋 관리부터 시작하도록)
|
|
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
if (savedData) {
|
|
localStorage.removeItem('castad_wizard_step');
|
|
localStorage.removeItem('castad_active_item');
|
|
}
|
|
setInitialTab('새 프로젝트 만들기');
|
|
setViewMode('generation_flow');
|
|
return;
|
|
}
|
|
|
|
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
|
|
try {
|
|
const { getKakaoLoginUrl } = await import('./utils/api');
|
|
const response = await getKakaoLoginUrl();
|
|
window.location.href = response.auth_url;
|
|
} catch (err) {
|
|
console.error('Failed to get Kakao login URL:', err);
|
|
alert(t('app.loginUrlFailed'));
|
|
}
|
|
};
|
|
|
|
const handleLoginSuccess = () => {
|
|
setInitialTab('새 프로젝트 만들기');
|
|
setViewMode('generation_flow');
|
|
};
|
|
|
|
const handleGoBack = () => {
|
|
// localStorage 정리
|
|
localStorage.removeItem(VIEW_MODE_KEY);
|
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
|
localStorage.removeItem('castad_wizard_step');
|
|
localStorage.removeItem('castad_active_item');
|
|
setViewMode('landing');
|
|
};
|
|
|
|
// Social OAuth 콜백 페이지 처리
|
|
const pathname = window.location.pathname;
|
|
|
|
// YouTube OAuth 콜백 처리 (Google에서 리다이렉트)
|
|
if (pathname === '/social/oauth/youtube/callback') {
|
|
return <YouTubeOAuthCallback />;
|
|
}
|
|
|
|
if (pathname === '/social/connect/success') {
|
|
return <SocialConnectSuccess />;
|
|
}
|
|
|
|
if (pathname === '/social/connect/error') {
|
|
return <SocialConnectError />;
|
|
}
|
|
|
|
// 카카오 콜백 처리 중 로딩 화면 표시
|
|
if (isProcessingCallback) {
|
|
return (
|
|
<div className="login-container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
<div style={{ textAlign: 'center', color: '#fff' }}>
|
|
<p style={{ fontSize: '18px' }}>{t('app.loginProcessing')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (viewMode === 'loading') {
|
|
return <LoadingSection />;
|
|
}
|
|
|
|
if (viewMode === 'analysis' && analysisData) {
|
|
return (
|
|
<AnalysisResultSection
|
|
onBack={handleGoBack}
|
|
onGenerate={handleToLogin}
|
|
data={analysisData}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (viewMode === 'login') {
|
|
return <LoginSection onBack={() => setViewMode('analysis')} onLogin={handleLoginSuccess} />;
|
|
}
|
|
|
|
if (viewMode === 'generation_flow') {
|
|
return (
|
|
<GenerationFlow
|
|
onHome={handleGoBack}
|
|
initialActiveItem={initialTab}
|
|
initialImageList={analysisData?.image_list || []}
|
|
businessInfo={analysisData?.processed_info}
|
|
initialAnalysisData={analysisData}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 로그인된 상태에서 "시작하기" 버튼 클릭
|
|
const handleHeaderStart = () => {
|
|
setInitialTab('새 프로젝트 만들기');
|
|
setViewMode('generation_flow');
|
|
};
|
|
|
|
return (
|
|
<main className="landing-container" ref={containerRef}>
|
|
<Header onStartClick={handleHeaderStart} />
|
|
<section className="landing-section">
|
|
<HeroSection
|
|
onAnalyze={handleStartAnalysis}
|
|
onAutocomplete={handleAutocomplete}
|
|
onTestData={handleTestData}
|
|
onNext={() => scrollToSection(1)}
|
|
error={error}
|
|
scrollProgress={scrollProgress}
|
|
/>
|
|
</section>
|
|
<section className="landing-section">
|
|
<WelcomeSection
|
|
onStartClick={() => scrollToSection(0)}
|
|
onNext={() => scrollToSection(0)}
|
|
/>
|
|
</section>
|
|
<section className="landing-section">
|
|
<DisplaySection onStartClick={() => scrollToSection(0)} />
|
|
</section>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default App;
|