refactor: main 머지로 복구된 랜딩을 다시 제거한다.
ADO2 콘텐츠를 첫 화면으로 되돌린다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
82b9fef3a1
commit
4dff60c1ba
399
src/App.tsx
399
src/App.tsx
@ -1,28 +1,16 @@
|
|||||||
|
|
||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||||
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
import SocialConnectSuccess from './pages/Social/SocialConnectSuccess';
|
||||||
import SocialConnectError from './pages/Social/SocialConnectError';
|
import SocialConnectError from './pages/Social/SocialConnectError';
|
||||||
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
|
||||||
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
|
|
||||||
import VideoDetailPage from './components/VideoDetailPage';
|
import VideoDetailPage from './components/VideoDetailPage';
|
||||||
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
import SsulDetailPage from './pages/Ssulbox/SsulDetailPage';
|
||||||
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
import { kakaoCallback, saveTokens, storeUtmFromUrl, trackViewContent, trackCompleteRegistration } from './utils/api';
|
||||||
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
|
import { clearSessionStorage } from './utils/storageKeys';
|
||||||
import { CrawlingResponse } from './types/api';
|
import { NAV } from './components/navItems';
|
||||||
|
|
||||||
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';
|
const SESSION_KEY = 'castad_session_active';
|
||||||
|
|
||||||
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
// 새 탭/새 창에서 접근 시 localStorage 초기화 (sessionStorage로 현재 세션 확인)
|
||||||
@ -31,15 +19,7 @@ const initializeOnNewSession = () => {
|
|||||||
|
|
||||||
if (!isExistingSession) {
|
if (!isExistingSession) {
|
||||||
// 새 세션이면 localStorage 정리하고 세션 표시
|
// 새 세션이면 localStorage 정리하고 세션 표시
|
||||||
localStorage.removeItem(VIEW_MODE_KEY);
|
clearSessionStorage();
|
||||||
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');
|
sessionStorage.setItem(SESSION_KEY, 'true');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -48,61 +28,8 @@ const initializeOnNewSession = () => {
|
|||||||
initializeOnNewSession();
|
initializeOnNewSession();
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const containerRef = useRef<HTMLElement>(null);
|
const [initialTab, setInitialTab] = useState<string>(NAV.HOME);
|
||||||
|
|
||||||
// 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 [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
|
||||||
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
|
|
||||||
const [scrollProgress, setScrollProgress] = useState(0);
|
|
||||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||||
|
|
||||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||||
@ -130,8 +57,8 @@ const App: React.FC = () => {
|
|||||||
setIsProcessingCallback(true);
|
setIsProcessingCallback(true);
|
||||||
handleKakaoCallback(code);
|
handleKakaoCallback(code);
|
||||||
}
|
}
|
||||||
// 콜백이 아닌 일반 진입이고 랜딩이 첫 화면이면 ViewContent 발화
|
// 콜백이 아닌 일반 진입 — 광고 유입 추적
|
||||||
else if (viewMode === 'landing') {
|
else {
|
||||||
trackViewContent();
|
trackViewContent();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -155,21 +82,15 @@ const App: React.FC = () => {
|
|||||||
await trackCompleteRegistration();
|
await trackCompleteRegistration();
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
// 로그인 전 특정 화면(예: /video/{id})에서 유도된 로그인이면 그 경로로 복귀
|
||||||
if (savedData) {
|
|
||||||
// 분석 데이터가 있으면 에셋 관리(step 1)부터 시작
|
|
||||||
// 이전에 저장된 wizard step이 URL 입력(-2) 등으로 남아있을 수 있으므로 초기화
|
|
||||||
localStorage.removeItem('castad_wizard_step');
|
|
||||||
localStorage.removeItem('castad_active_item');
|
|
||||||
}
|
|
||||||
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
const redirectPath = sessionStorage.getItem('castad_login_redirect');
|
||||||
sessionStorage.removeItem('castad_login_redirect');
|
sessionStorage.removeItem('castad_login_redirect');
|
||||||
if (redirectPath && redirectPath !== '/') {
|
if (redirectPath && redirectPath !== '/') {
|
||||||
window.location.href = redirectPath;
|
window.location.href = redirectPath;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInitialTab('새 프로젝트 만들기');
|
// 그 외(사이드바 게이트 등)는 GenerationFlow 가 castad_login_next_item 으로 자체 복귀 처리
|
||||||
setViewMode('generation_flow');
|
setInitialTab(NAV.HOME);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Token callback failed:', err);
|
console.error('Token callback failed:', err);
|
||||||
alert(t('app.loginFailed'));
|
alert(t('app.loginFailed'));
|
||||||
@ -203,231 +124,6 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
setAfterLoadTarget('analysis');
|
|
||||||
setViewMode('loading');
|
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
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));
|
|
||||||
saveSearchHistory({ type: 'url', value: url });
|
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Crawling failed:', err);
|
|
||||||
setError(t('app.analysisError'));
|
|
||||||
setViewMode('landing');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 업체명 자동완성으로 분석 시작
|
|
||||||
const handleAutocomplete = async (request: AutocompleteRequest) => {
|
|
||||||
setAfterLoadTarget('analysis');
|
|
||||||
setViewMode('loading');
|
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
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));
|
|
||||||
saveSearchHistory({ type: 'name', value: request.title.replace(/<[^>]*>/g, ''), address: request.address, roadAddress: request.roadAddress });
|
|
||||||
setIsAnalysisComplete(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Autocomplete failed:', err);
|
|
||||||
setError(t('app.autocompleteError'));
|
|
||||||
setViewMode('landing');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
|
|
||||||
const handleManualInput = async (businessName: string, address: string, category: string) => {
|
|
||||||
setAfterLoadTarget('generation_flow');
|
|
||||||
setViewMode('loading');
|
|
||||||
setIsAnalysisComplete(false);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await marketingAnalysis(businessName, address, category);
|
|
||||||
|
|
||||||
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 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 콜백 페이지 처리
|
// Social OAuth 콜백 페이지 처리
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
@ -450,12 +146,14 @@ const App: React.FC = () => {
|
|||||||
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
return <VideoDetailPage videoId={videoDetailMatch[1]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 썰박스 공유 페이지 (/ssul/{id}) — 위와 동일한 패턴, 비로그인 열람 가능.
|
||||||
|
// /video/{id} 와 경로를 나눈 이유: video.id 와 ssul_content.id 가 독립 시퀀스라
|
||||||
|
// 값이 겹치므로, 한 경로로 합치면 다른 종류의 콘텐츠가 열린다.
|
||||||
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
const ssulDetailMatch = pathname.match(/^\/ssul\/(\d+)$/);
|
||||||
if (ssulDetailMatch) {
|
if (ssulDetailMatch) {
|
||||||
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
return <SsulDetailPage contentId={ssulDetailMatch[1]} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||||
if (isProcessingCallback) {
|
if (isProcessingCallback) {
|
||||||
return (
|
return (
|
||||||
@ -467,71 +165,8 @@ const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewMode === 'loading') {
|
// 그 외 전 경로는 ADO2 콘텐츠 갤러리를 첫 화면으로 하는 GenerationFlow 로 수렴
|
||||||
return (
|
return <GenerationFlow initialActiveItem={initialTab} />;
|
||||||
<LoadingSection
|
|
||||||
isComplete={isAnalysisComplete}
|
|
||||||
onComplete={() => setViewMode(afterLoadTarget)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
onManualInput={handleManualInput}
|
|
||||||
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;
|
export default App;
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
|
||||||
import LanguageSwitch from './LanguageSwitch';
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
onStartClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const loggedIn = isLoggedIn();
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
if (isLoading) return;
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await getKakaoLoginUrl();
|
|
||||||
window.location.href = response.auth_url;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get Kakao login URL:', err);
|
|
||||||
alert(t('header.loginFailedAlert'));
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStart = () => {
|
|
||||||
if (onStartClick) {
|
|
||||||
onStartClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="landing-header">
|
|
||||||
<div className="header-logo">
|
|
||||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
|
||||||
</div>
|
|
||||||
<div className="header-actions">
|
|
||||||
<LanguageSwitch />
|
|
||||||
{loggedIn ? (
|
|
||||||
<button
|
|
||||||
className="header-start-btn"
|
|
||||||
onClick={handleStart}
|
|
||||||
>
|
|
||||||
{t('header.start')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="header-login-btn"
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? t('header.loading') : t('header.login')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@ -313,7 +313,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||||||
setImageList(prev => [...newImages, ...prev]);
|
setImageList(prev => [...newImages, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고, 부모가 있으면 랜딩으로 돌아간다.
|
// 홈 버튼(로고) 클릭 시 프로젝트 상태를 비우고 ADO2 콘텐츠로 돌아간다.
|
||||||
const handleHome = () => {
|
const handleHome = () => {
|
||||||
clearProjectStorage();
|
clearProjectStorage();
|
||||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import Footer from '../../components/Footer';
|
|
||||||
|
|
||||||
interface DisplaySectionProps {
|
|
||||||
onStartClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DisplaySection: React.FC<DisplaySectionProps> = ({ onStartClick }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
// YouTube Shorts 영상 ID들
|
|
||||||
const videos = [
|
|
||||||
{ id: 1, videoId: 'M3iuPZ59X1I' },
|
|
||||||
{ id: 2, videoId: 'JxWQxELDHSs' },
|
|
||||||
{ id: 3, videoId: 'c2ZdwhaB7S4' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="display-section">
|
|
||||||
<div className="content-safe-area">
|
|
||||||
{/* Main visual frames container */}
|
|
||||||
<div className="display-frames">
|
|
||||||
{videos.map((video, index) => (
|
|
||||||
<div
|
|
||||||
key={video.id}
|
|
||||||
className={`display-frame ${index === 2 ? 'display-frame-hidden-mobile' : ''}`}
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
src={`https://www.youtube.com/embed/${video.videoId}?autoplay=1&mute=1&loop=1&playlist=${video.videoId}&controls=0&showinfo=0&rel=0&modestbranding=1&playsinline=1`}
|
|
||||||
title={`YouTube Shorts ${video.id}`}
|
|
||||||
frameBorder="0"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowFullScreen
|
|
||||||
className="display-video"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<button onClick={onStartClick} className="display-button">
|
|
||||||
{t('landing.display.startButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DisplaySection;
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import SearchInputForm from '../../components/SearchInputForm';
|
|
||||||
import { SearchType } from '../../components/SearchInputForm';
|
|
||||||
|
|
||||||
// Orb configuration with movement zones to prevent overlap
|
|
||||||
interface OrbConfig {
|
|
||||||
id: string;
|
|
||||||
size: number;
|
|
||||||
initialX: number;
|
|
||||||
initialY: number;
|
|
||||||
color: string;
|
|
||||||
minX: number;
|
|
||||||
maxX: number;
|
|
||||||
minY: number;
|
|
||||||
maxY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orbConfigs: OrbConfig[] = [
|
|
||||||
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
|
|
||||||
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
|
|
||||||
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
|
|
||||||
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
|
|
||||||
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, 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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface HeroSectionProps {
|
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
|
||||||
onManualInput?: (businessName: string, address: string, category: string) => void;
|
|
||||||
onNext?: () => void;
|
|
||||||
error?: string | null;
|
|
||||||
scrollProgress?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onNext, error: externalError, scrollProgress = 0 }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
|
||||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
||||||
const animationRefs = useRef<number[]>([]);
|
|
||||||
|
|
||||||
// Orb 랜덤 이동 애니메이션
|
|
||||||
useEffect(() => {
|
|
||||||
const moveOrb = (orb: HTMLDivElement, index: number) => {
|
|
||||||
const config = orbConfigs[index];
|
|
||||||
let currentX = config.initialX;
|
|
||||||
let currentY = config.initialY;
|
|
||||||
let targetX = currentX;
|
|
||||||
let targetY = currentY;
|
|
||||||
let scale = 1;
|
|
||||||
let targetScale = 1;
|
|
||||||
let isFirstMove = true;
|
|
||||||
|
|
||||||
const generateNewTarget = () => {
|
|
||||||
const rangeX = config.maxX - config.minX;
|
|
||||||
const rangeY = config.maxY - config.minY;
|
|
||||||
if (isFirstMove) {
|
|
||||||
const smallRangeX = rangeX * 0.3;
|
|
||||||
const smallRangeY = rangeY * 0.3;
|
|
||||||
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
|
|
||||||
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
|
|
||||||
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
|
|
||||||
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
|
|
||||||
isFirstMove = false;
|
|
||||||
} else {
|
|
||||||
targetX = config.minX + Math.random() * rangeX;
|
|
||||||
targetY = config.minY + Math.random() * rangeY;
|
|
||||||
}
|
|
||||||
targetScale = 0.9 + Math.random() * 0.2;
|
|
||||||
};
|
|
||||||
|
|
||||||
const animate = () => {
|
|
||||||
const speed = 0.003;
|
|
||||||
currentX += (targetX - currentX) * speed;
|
|
||||||
currentY += (targetY - currentY) * speed;
|
|
||||||
scale += (targetScale - scale) * speed;
|
|
||||||
orb.style.left = `${currentX}%`;
|
|
||||||
orb.style.top = `${currentY}%`;
|
|
||||||
orb.style.transform = `scale(${scale})`;
|
|
||||||
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
|
|
||||||
if (distance < 1) generateNewTarget();
|
|
||||||
animationRefs.current[index] = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
generateNewTarget();
|
|
||||||
animate();
|
|
||||||
};
|
|
||||||
|
|
||||||
orbRefs.current.forEach((orb, index) => {
|
|
||||||
if (orb) moveOrb(orb, index);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
animationRefs.current.forEach(id => cancelAnimationFrame(id));
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="hero-section">
|
|
||||||
{/* Animated background orbs */}
|
|
||||||
<div
|
|
||||||
className="hero-bg-orbs"
|
|
||||||
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
|
|
||||||
>
|
|
||||||
{orbConfigs.map((config, index) => (
|
|
||||||
<div
|
|
||||||
key={config.id}
|
|
||||||
ref={el => { orbRefs.current[index] = el; }}
|
|
||||||
className="hero-orb-random"
|
|
||||||
style={{
|
|
||||||
width: `${config.size}px`,
|
|
||||||
height: `${config.size}px`,
|
|
||||||
background: config.color,
|
|
||||||
left: `${config.initialX}%`,
|
|
||||||
top: `${config.initialY}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hero-content">
|
|
||||||
<img
|
|
||||||
src="/assets/images/ADO2_with Slogan_white.svg"
|
|
||||||
alt="ADO2"
|
|
||||||
className="hero-logo"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SearchInputForm
|
|
||||||
onAnalyze={onAnalyze}
|
|
||||||
onAutocomplete={onAutocomplete}
|
|
||||||
onManualInput={onManualInput}
|
|
||||||
onManualButtonClick={() => {
|
|
||||||
if (isLoggedIn()) {
|
|
||||||
setIsManualModalOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsLoginPromptOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={externalError || null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Indicator */}
|
|
||||||
<button onClick={onNext} className="scroll-indicator">
|
|
||||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
|
||||||
<div className="scroll-indicator-icon">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M7 10l5 5 5-5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address, category) => {
|
|
||||||
setIsManualModalOpen(false);
|
|
||||||
onManualInput?.(businessName, address, category);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoginPromptOpen && (
|
|
||||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeroSection;
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
interface WelcomeSectionProps {
|
|
||||||
onStartClick?: () => void;
|
|
||||||
onNext?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WelcomeSection: React.FC<WelcomeSectionProps> = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: t('landing.welcome.feature1Title'),
|
|
||||||
description: t('landing.welcome.feature1Desc'),
|
|
||||||
iconBg: '#9BCACC',
|
|
||||||
icon: '/assets/images/icon-analysis.svg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: t('landing.welcome.feature2Title'),
|
|
||||||
description: t('landing.welcome.feature2Desc'),
|
|
||||||
iconBg: '#DFC7FD',
|
|
||||||
icon: '/assets/images/icon-content.svg'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: t('landing.welcome.feature3Title'),
|
|
||||||
description: t('landing.welcome.feature3Desc'),
|
|
||||||
iconBg: '#D4FDF3',
|
|
||||||
icon: '/assets/images/icon-deploy.svg'
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="welcome-section">
|
|
||||||
<div className="welcome-content">
|
|
||||||
{/* Star Icon - Top Center */}
|
|
||||||
<div className="welcome-star">
|
|
||||||
<img src="/assets/images/star-icon.svg" alt="star" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="welcome-header">
|
|
||||||
<h2 className="welcome-title">{t('landing.welcome.title')}</h2>
|
|
||||||
<p className="welcome-subtitle">{t('landing.welcome.subtitle')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feature Cards */}
|
|
||||||
<div className="feature-grid">
|
|
||||||
{features.map((feature) => (
|
|
||||||
<div key={feature.id} className="feature-card">
|
|
||||||
<div className="feature-number-badge">
|
|
||||||
<span>{feature.id}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="feature-card-title">{feature.title}</h3>
|
|
||||||
<div className="feature-icon-box" style={{ backgroundColor: feature.iconBg }}>
|
|
||||||
<img src={feature.icon} alt={feature.title} className="feature-icon-img" />
|
|
||||||
</div>
|
|
||||||
<p className="feature-card-description">{feature.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WelcomeSection;
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { getKakaoLoginUrl, kakaoCallback, trackCompleteRegistration } from '../../utils/api';
|
|
||||||
|
|
||||||
interface LoginSectionProps {
|
|
||||||
onBack: () => void;
|
|
||||||
onLogin: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
|
|
||||||
useEffect(() => {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const code = urlParams.get('code');
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
handleKakaoCallback(code);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKakaoCallback = async (code: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await kakaoCallback(code);
|
|
||||||
|
|
||||||
// 신규 가입이면 Meta CompleteRegistration 전환 이벤트 발화
|
|
||||||
// (서버가 계정당 최초 1회 판정 — App.tsx 경로와 중복 호출돼도 무해)
|
|
||||||
if (response.is_new_user) {
|
|
||||||
await trackCompleteRegistration();
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL에서 code 파라미터 제거
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('code');
|
|
||||||
window.history.replaceState({}, document.title, url.pathname);
|
|
||||||
|
|
||||||
// 로그인 성공
|
|
||||||
onLogin();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Kakao callback failed:', err);
|
|
||||||
setError(t('login.kakaoLoginFailed'));
|
|
||||||
|
|
||||||
// URL에서 code 파라미터 제거
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.delete('code');
|
|
||||||
window.history.replaceState({}, document.title, url.pathname);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKakaoLogin = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await getKakaoLoginUrl();
|
|
||||||
|
|
||||||
// 카카오 로그인 페이지로 리다이렉트
|
|
||||||
window.location.href = response.auth_url;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to get Kakao login URL:', err);
|
|
||||||
setError(t('login.loginUrlFailed'));
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="login-container">
|
|
||||||
{/* Back Button */}
|
|
||||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
|
||||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
|
||||||
<span>{t('login.back')}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="login-content">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="login-logo">
|
|
||||||
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="login-error">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Kakao Login Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleKakaoLogin}
|
|
||||||
className="btn-kakao"
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? t('login.loggingIn') : t('login.kakaoStart')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginSection;
|
|
||||||
Loading…
Reference in New Issue
Block a user