o2o-castad-frontend/src/App.tsx

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;