From 662b6b80bc15f34785942fea33b1ec2545ea74ad Mon Sep 17 00:00:00 2001 From: hbyang Date: Wed, 21 Jan 2026 16:09:29 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B0=8F=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- index.css | 710 +++++++++++++------ public/assets/images/ado2-header-logo.svg | 3 + public/assets/images/ado2-logo.svg | 31 + public/assets/images/input-clear-icon.svg | 4 + src/App.tsx | 196 ++++- src/components/Header.tsx | 54 +- src/components/Sidebar.tsx | 2 +- src/pages/Analysis/AnalysisResultSection.tsx | 161 ++--- src/pages/Dashboard/GenerationFlow.tsx | 196 ++++- src/pages/Dashboard/UrlInputContent.tsx | 69 ++ src/pages/Landing/HeroSection.tsx | 23 +- src/pages/Login/LoginSection.tsx | 76 +- src/types/api.ts | 40 ++ src/utils/api.ts | 161 +++++ 15 files changed, 1394 insertions(+), 336 deletions(-) create mode 100644 public/assets/images/ado2-header-logo.svg create mode 100644 public/assets/images/ado2-logo.svg create mode 100644 public/assets/images/input-clear-icon.svg create mode 100644 src/pages/Dashboard/UrlInputContent.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6500bf2..700b103 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,9 @@ "Bash(rmdir:*)", "Bash(rm:*)", "Bash(npm run build:*)", - "Bash(python3:*)" + "Bash(python3:*)", + "mcp__figma__get_figma_data", + "mcp__figma__download_figma_images" ] } } diff --git a/index.css b/index.css index ad7be3f..eb40e60 100644 --- a/index.css +++ b/index.css @@ -1821,87 +1821,108 @@ Analysis Result Page Components ===================================================== */ -/* Analysis Container */ +/* Analysis Container - 높이 제한 없음, 자연 스크롤 */ .analysis-container { width: 100%; - height: 100dvh; color: var(--color-text-white); display: flex; flex-direction: column; align-items: center; - padding: 1.5rem 1rem; - background-color: var(--color-bg-dark); - overflow-y: auto; - overflow-x: hidden; + background-color: #002224; + box-sizing: border-box; + padding-bottom: 100px; /* 고정 버튼 영역 확보 */ +} + +/* Header Area (Back Button + Title) */ +.analysis-header-area { + width: 100%; + max-width: 1440px; + padding: 8px 16px; box-sizing: border-box; } @media (min-width: 768px) { - .analysis-container { - padding: 2rem 2rem; - } -} - -@media (min-width: 1024px) { - .analysis-container { - padding: 2.5rem 5%; - } -} - -@media (min-width: 1440px) { - .analysis-container { - /* 피그마: 1440px 화면에서 양쪽 200px 패딩 = 약 13.9% */ - padding: 2.5rem calc((100% - 1040px) / 2); + .analysis-header-area { + padding: 8px 32px; } } /* Analysis Header */ .analysis-header { width: 100%; - max-width: 1040px; display: flex; flex-direction: column; align-items: center; text-align: center; - margin-bottom: 1.5rem; - flex-shrink: 0; + padding: 16px 0; } @media (min-width: 768px) { .analysis-header { - margin-bottom: 2rem; + padding: 24px 0; } } .analysis-icon { color: var(--color-purple); - margin-bottom: 0.75rem; + margin-bottom: 12px; +} + +.analysis-icon svg { + width: 32px; + height: 32px; +} + +@media (min-width: 768px) { + .analysis-icon svg { + width: 40px; + height: 40px; + } } /* Analysis Grid */ .analysis-grid { width: 100%; - max-width: 1040px; + max-width: 1440px; display: flex; flex-direction: column; gap: 16px; - flex-shrink: 0; + padding: 0 16px; box-sizing: border-box; } @media (min-width: 768px) { .analysis-grid { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: start; + gap: 24px; + padding: 0 32px; } } -/* Brand Identity Card */ +@media (min-width: 1024px) { + .analysis-grid { + flex-direction: row; + gap: 40px; + padding: 0 60px; + } +} + +@media (min-width: 1280px) { + .analysis-grid { + padding: 0 120px; + } +} + +@media (min-width: 1440px) { + .analysis-grid { + padding: 0 180px; + } +} + +/* Brand Identity Card - 왼쪽 */ .brand-identity-card { background-color: #01393B; - border-radius: 40px; - padding: 24px; + border-radius: 24px; + padding: 20px; display: flex; flex-direction: column; gap: 20px; @@ -1913,8 +1934,15 @@ @media (min-width: 768px) { .brand-identity-card { + border-radius: 40px; padding: 32px; - max-height: 453px; + gap: 32px; + } +} + +@media (min-width: 1024px) { + .brand-identity-card { + flex: 1; } } @@ -1922,152 +1950,86 @@ display: flex; align-items: center; gap: 8px; - margin-bottom: 0; + flex-wrap: wrap; +} + +.brand-content { + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (min-width: 768px) { + .brand-content { + gap: 16px; + } } .brand-name { - font-size: 28px; - font-weight: 600; + font-size: 20px; + font-weight: 700; color: #E5F1F2; letter-spacing: -0.006em; - margin-bottom: 0; + margin: 0; + line-height: 1.2; } @media (min-width: 768px) { .brand-name { - font-size: 32px; + font-size: 24px; } } .brand-location { color: #6AB0B3; - font-size: 14px; + font-size: 13px; letter-spacing: -0.006em; - margin-bottom: 0; + margin: 0; + line-height: 1.2; +} + +@media (min-width: 768px) { + .brand-location { + font-size: 14px; + } } .brand-subtitle { color: #6AB0B3; - font-size: 14px; + font-size: 13px; font-weight: 400; letter-spacing: -0.006em; } +@media (min-width: 768px) { + .brand-subtitle { + font-size: 14px; + } +} + .brand-info { display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; gap: 4px; + flex-wrap: wrap; } -/* Report Section */ -.report-section { - flex: 1; - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; -} - -.report-toggle { - font-size: 14px; - color: #6AB0B3; - background: none; - border: none; - cursor: pointer; - transition: color 0.2s ease; - padding: 0; - text-align: left; -} - -.report-toggle:hover { - color: #AE72F9; -} - +/* Report Content */ .report-content { - color: #E5F1F2; - font-size: 17px; + color: #CEE5E6; + font-family: 'Pretendard', sans-serif; + font-size: 15px; + font-weight: 500; line-height: 1.5; letter-spacing: -0.006em; - overflow-y: auto; - flex: 1; - min-height: 0; } -.report-sections { - display: flex; - flex-direction: column; - gap: 16px; -} - -.report-content::-webkit-scrollbar { - width: 6px; -} - -.report-content::-webkit-scrollbar-track { - background: transparent; -} - -.report-content::-webkit-scrollbar-thumb { - background: #379599; - border-radius: 3px; -} - -.report-content::-webkit-scrollbar-thumb:hover { - background: #4AABAF; -} - -.report-section-title { - color: var(--color-mint); - font-size: var(--text-lg); - font-weight: 600; - margin-bottom: 0.5rem; -} - -/* Image Preview */ -.image-preview-section { - margin-top: auto; - padding-top: 1rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); -} - -.image-preview-title { - color: #6AB0B3; - font-size: 14px; - font-weight: 600; - letter-spacing: -0.006em; - margin-bottom: 12px; - display: block; -} - -.image-preview-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; -} - -.image-preview-item { - aspect-ratio: 1; - border-radius: var(--radius-lg); - overflow: hidden; - background-color: var(--color-bg-dark); -} - -.image-preview-item img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform var(--transition-normal); -} - -.image-preview-item:hover img { - transform: scale(1.05); -} - -.image-preview-more { - color: var(--color-text-gray-500); - font-size: var(--text-sm); - margin-top: 0.5rem; - text-align: center; +@media (min-width: 768px) { + .report-content { + font-size: 17px; + line-height: 1.45; + } } /* Right Side Cards Container */ @@ -2076,88 +2038,346 @@ flex-direction: column; gap: 16px; width: 100%; - max-height: 453px; } -/* Feature Card for Analysis Page (Selling Points & Keywords) */ +@media (min-width: 768px) { + .analysis-cards-column { + gap: 24px; + } +} + +@media (min-width: 1024px) { + .analysis-cards-column { + flex: 1; + } +} + +/* Feature Card for Analysis Page */ .analysis-cards-column .feature-card { background-color: #01393B; - border-radius: 40px; - padding: 24px; + border-radius: 24px; + padding: 20px; border: none; box-shadow: none; - flex: 1; display: flex; flex-direction: column; - gap: 16px; + gap: 20px; width: 100%; box-sizing: border-box; - min-height: 0; - overflow: hidden; } @media (min-width: 768px) { .analysis-cards-column .feature-card { + border-radius: 40px; padding: 32px; + gap: 32px; } } +/* Selling Points Card */ +.selling-points-card { + flex: 1; +} + +/* Selling Points Grid - 2열 그리드 */ +.selling-points-grid { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +@media (min-width: 480px) { + .selling-points-grid { + grid-template-columns: 1fr 1fr; + gap: 0; + } +} + +/* Selling Point Item */ +.selling-point-item { + background-color: #034A4D; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + border-radius: 12px; +} + +@media (min-width: 480px) { + .selling-point-item { + padding: 24px 20px; + gap: 16px; + border-radius: 0; + } + + /* 첫 번째 행 */ + .selling-point-item:nth-child(1) { + border-radius: 16px 0 0 0; + } + .selling-point-item:nth-child(2) { + border-radius: 0 16px 0 0; + } + + /* 마지막 행 */ + .selling-point-item:nth-last-child(2):nth-child(odd) { + border-radius: 0 0 0 16px; + } + .selling-point-item:nth-last-child(1):nth-child(even) { + border-radius: 0 0 16px 0; + } + + /* 홀수 개일 때 마지막 아이템 */ + .selling-point-item:last-child:nth-child(odd) { + border-radius: 0 0 0 16px; + } +} + +.selling-point-title { + font-family: 'Pretendard', sans-serif; + font-size: 13px; + font-weight: 600; + color: #6AB0B3; + letter-spacing: -0.006em; + line-height: 1; +} + +@media (min-width: 768px) { + .selling-point-title { + font-size: 14px; + } +} + +.selling-point-content { + display: flex; + flex-direction: column; + gap: 6px; +} + +@media (min-width: 768px) { + .selling-point-content { + gap: 8px; + } +} + +.selling-point-content p { + font-family: 'Pretendard', sans-serif; + font-size: 15px; + font-weight: 500; + color: #CEE5E6; + letter-spacing: -0.006em; + line-height: 1.2; + margin: 0; +} + +@media (min-width: 768px) { + .selling-point-content p { + font-size: 17px; + line-height: 1; + } +} + +/* Keywords Card */ +.keywords-card { + flex-shrink: 0; +} + /* Tags Wrapper */ .tags-wrapper { display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px; align-content: flex-start; - overflow-y: auto; - flex: 1; - min-height: 0; -} - -.tags-wrapper::-webkit-scrollbar { - width: 6px; -} - -.tags-wrapper::-webkit-scrollbar-track { - background: transparent; -} - -.tags-wrapper::-webkit-scrollbar-thumb { - background: #379599; - border-radius: 3px; -} - -.tags-wrapper::-webkit-scrollbar-thumb:hover { - background: #4AABAF; -} - -/* Feature Tag */ -.feature-tag { - padding: 8px 16px; - background-color: #046266; - border-radius: 999px; - font-size: 17px; - font-weight: 400; - color: #FFFFFF; - border: none; -} - -/* Analysis Bottom Button */ -.analysis-bottom { - width: 100%; - max-width: 1040px; - margin-top: 2rem; - padding-bottom: 1rem; - display: flex; - justify-content: center; - flex-shrink: 0; } @media (min-width: 768px) { - .analysis-bottom { - padding-bottom: 1.5rem; + .tags-wrapper { + gap: 8px; } } +/* Feature Tag - pill 형태 */ +.feature-tag { + padding: 6px 12px; + background-color: #034A4D; + border-radius: 999px; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 500; + color: #CEE5E6; + letter-spacing: -0.006em; + border: none; +} + +@media (min-width: 768px) { + .feature-tag { + padding: 8px 16px; + font-size: 17px; + } +} + +/* Analysis Bottom Button - 화면 하단 고정 */ +.analysis-bottom { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + display: flex; + justify-content: center; + box-sizing: border-box; + background: linear-gradient(to top, #002224 60%, transparent); + z-index: 100; +} + +/* Sidebar가 있을 때 버튼 위치 조정 (md breakpoint) */ +@media (min-width: 768px) { + .analysis-bottom { + left: 280px; + padding: 24px 32px; + } +} + +.analysis-bottom .btn-primary { + width: 160px; + height: 48px; + padding: 10px 24px; + background-color: #AE72F9; + border-radius: 999px; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 600; + color: #FFFFFF; + letter-spacing: -0.006em; + border: none; + cursor: pointer; + transition: background-color 0.2s ease; + box-shadow: 0 4px 20px rgba(174, 114, 249, 0.4); +} + +.analysis-bottom .btn-primary:hover { + background-color: #9B5DE5; +} + +/* ===================================================== + URL Input Content (GenerationFlow 내 URL 입력 단계) + ===================================================== */ + +.url-input-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #002224; + padding: 2rem; + box-sizing: border-box; +} + +.url-input-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 600px; + width: 100%; +} + +.url-input-icon { + color: #AE72F9; + margin-bottom: 24px; +} + +.url-input-title { + font-family: 'Pretendard', sans-serif; + font-size: 40px; + font-weight: 600; + color: #E5F1F2; + letter-spacing: -0.006em; + margin: 0 0 8px 0; + line-height: 1.2; +} + +.url-input-subtitle { + font-family: 'Pretendard', sans-serif; + font-size: 14px; + font-weight: 400; + color: #9BCACC; + letter-spacing: -0.006em; + margin: 0 0 40px 0; +} + +.url-input-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.url-input-wrapper { + display: flex; + gap: 12px; + width: 100%; +} + +.url-input-field { + flex: 1; + padding: 16px 20px; + background-color: #01393B; + border: 1px solid #034A4D; + border-radius: 12px; + font-family: 'Pretendard', sans-serif; + font-size: 16px; + color: #E5F1F2; + outline: none; + transition: border-color 0.2s ease; +} + +.url-input-field::placeholder { + color: #6AB0B3; +} + +.url-input-field:focus { + border-color: #AE72F9; +} + +.url-input-button { + padding: 16px 32px; + background-color: #AE72F9; + border: none; + border-radius: 12px; + font-family: 'Pretendard', sans-serif; + font-size: 16px; + font-weight: 600; + color: #FFFFFF; + cursor: pointer; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.url-input-button:hover:not(:disabled) { + background-color: #9B5DE5; +} + +.url-input-button:disabled { + background-color: #4A5568; + cursor: not-allowed; +} + +.url-input-error { + color: #F56565; + font-family: 'Pretendard', sans-serif; + font-size: 14px; + margin: 0; + text-align: left; +} + +.url-input-guide { + font-family: 'Pretendard', sans-serif; + font-size: 13px; + color: #6AB0B3; + margin: 24px 0 0 0; +} + /* ===================================================== Landing Page Components ===================================================== */ @@ -2322,10 +2542,23 @@ background-color: #ffffff; border-radius: 999px; padding: 11px 24px; + display: flex; + align-items: center; + gap: 10px; + transition: box-shadow 0.2s ease; + box-sizing: border-box; +} + +.hero-input-wrapper.focused { + box-shadow: 0 0 0 4px #379599; +} + +.hero-input-wrapper.error { + box-shadow: 0 0 0 1px #F87171; } .hero-input { - width: 100%; + flex: 1; background: transparent; border: none; color: #6AB0B3; @@ -2333,6 +2566,7 @@ font-weight: 600; letter-spacing: -0.006em; text-align: left; + min-width: 0; } .hero-input:focus { @@ -2343,8 +2577,27 @@ color: #6AB0B3; } -.hero-input.error { - box-shadow: none; +.hero-input.has-value { + color: #002224; + font-weight: 500; +} + +.hero-input-clear { + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.hero-input-clear img { + width: 24px; + height: 24px; } .hero-input-hint { @@ -3346,6 +3599,31 @@ transform: scale(0.95); } +.btn-kakao:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-kakao:disabled:hover { + background-color: #FEE500; + transform: none; + box-shadow: none; +} + +/* Login Error */ +.login-error { + color: #FF6B6B; + font-size: 14px; + text-align: center; + padding: 12px 16px; + background-color: rgba(255, 107, 107, 0.1); + border-radius: 8px; + margin-bottom: 16px; + max-width: 296px; + width: 100%; +} + /* Legacy styles - keep for backward compatibility */ .login-title { font-family: 'Playfair Display', serif; @@ -4308,6 +4586,30 @@ transform: scale(0.95); } +.header-start-btn { + padding: 10px 24px; + border-radius: 999px; + background-color: #AE72F9; + color: #FFFFFF; + font-weight: 600; + font-size: 14px; + border: none; + cursor: pointer; + transition: all var(--transition-normal); + letter-spacing: -0.006em; + line-height: 1.19; +} + +.header-start-btn:hover { + background-color: #9B5DE5; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3); +} + +.header-start-btn:active { + transform: scale(0.95); +} + .header-avatar { width: 2rem; height: 2rem; diff --git a/public/assets/images/ado2-header-logo.svg b/public/assets/images/ado2-header-logo.svg new file mode 100644 index 0000000..45c78da --- /dev/null +++ b/public/assets/images/ado2-header-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/images/ado2-logo.svg b/public/assets/images/ado2-logo.svg new file mode 100644 index 0000000..0d9c2a8 --- /dev/null +++ b/public/assets/images/ado2-logo.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/assets/images/input-clear-icon.svg b/public/assets/images/input-clear-icon.svg new file mode 100644 index 0000000..7d0670c --- /dev/null +++ b/public/assets/images/input-clear-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/App.tsx b/src/App.tsx index bcb2395..ea670b0 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,7 @@ 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 { crawlUrl } from './utils/api'; +import { crawlUrl, kakaoCallback, isLoggedIn, saveTokens } from './utils/api'; import { CrawlingResponse } from './types/api'; type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow'; @@ -46,15 +46,116 @@ const App: React.FC = () => { const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null; const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY); - const [viewMode, setViewMode] = useState( - savedViewMode === 'generation_flow' ? 'generation_flow' : 'landing' - ); + // 저장된 분석 데이터 파싱 및 유효성 검사 + const parseSavedAnalysisData = (): CrawlingResponse | null => { + if (!savedAnalysisData) return null; + try { + const data = JSON.parse(savedAnalysisData) as CrawlingResponse; + // 기본값 보장 + if (data.marketing_analysis) { + data.marketing_analysis.tags = data.marketing_analysis.tags || []; + data.marketing_analysis.facilities = data.marketing_analysis.facilities || []; + data.marketing_analysis.report = data.marketing_analysis.report || ''; + } + 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(getInitialViewMode()); const [initialTab, setInitialTab] = useState('새 프로젝트 만들기'); const [analysisData, setAnalysisData] = useState( - savedAnalysisData ? JSON.parse(savedAnalysisData) : null + parseSavedAnalysisData() ); const [error, setError] = useState(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'); + + // 백엔드에서 토큰을 URL 파라미터로 전달한 경우 + if (accessToken && refreshToken && !isProcessingCallback) { + setIsProcessingCallback(true); + handleTokenCallback(accessToken, refreshToken); + } + // 기존 code 방식 (Redirect URI가 프론트엔드인 경우) + else if (code && !isProcessingCallback) { + 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); + + // 로그인 성공 - 분석 데이터 유무에 따라 분기 + // 비로그인 상태에서 URL 입력 후 브랜드 분석을 본 경우 → 바로 generation_flow로 (URL 입력 스킵) + // 홈에서 바로 로그인한 경우 → generation_flow로 (URL 입력 필요) + const savedData = localStorage.getItem(ANALYSIS_DATA_KEY); + if (savedData) { + // 분석 데이터가 있으면 바로 에셋 관리로 + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + } else { + // 분석 데이터가 없으면 URL 입력부터 시작하도록 generation_flow로 + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + } + } catch (err) { + console.error('Token callback failed:', err); + alert('로그인 처리에 실패했습니다. 다시 시도해주세요.'); + } 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('카카오 로그인에 실패했습니다. 다시 시도해주세요.'); + + // 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(() => { @@ -88,6 +189,43 @@ const App: React.FC = () => { } }; + // 크롤링 응답 유효성 검사 + 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.tags) { + data.marketing_analysis.tags = []; + } + if (!data.marketing_analysis.facilities) { + data.marketing_analysis.facilities = []; + } + if (!data.marketing_analysis.report) { + data.marketing_analysis.report = ''; + } + + // 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; @@ -96,18 +234,40 @@ const App: React.FC = () => { try { const data = await crawlUrl(url); + + // 응답 유효성 검사 + if (!validateCrawlingResponse(data)) { + throw new Error('유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.'); + } + setAnalysisData(data); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); setViewMode('analysis'); } catch (err) { console.error('Crawling failed:', err); - setError('분석 중 오류가 발생했습니다. 다시 시도해주세요.'); + const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.'; + setError(errorMessage); setViewMode('landing'); } }; - const handleToLogin = () => { - setViewMode('login'); + const handleToLogin = async () => { + // 이미 로그인된 상태면 바로 generation_flow로 이동 + if (isLoggedIn()) { + 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('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.'); + } }; const handleLoginSuccess = () => { @@ -124,6 +284,17 @@ const App: React.FC = () => { setViewMode('landing'); }; + // 카카오 콜백 처리 중 로딩 화면 표시 + if (isProcessingCallback) { + return ( +
+
+

로그인 처리 중...

+
+
+ ); + } + if (viewMode === 'loading') { return ; } @@ -149,13 +320,20 @@ const App: React.FC = () => { initialActiveItem={initialTab} initialImageList={analysisData?.image_list || []} businessInfo={analysisData?.processed_info} + initialAnalysisData={analysisData} /> ); } + // 로그인된 상태에서 "시작하기" 버튼 클릭 + const handleHeaderStart = () => { + setInitialTab('새 프로젝트 만들기'); + setViewMode('generation_flow'); + }; + return (
-
+
void; +} + +const Header: React.FC = ({ onStartClick }) => { + 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('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.'); + setIsLoading(false); + } + }; + + const handleStart = () => { + if (onStartClick) { + onStartClick(); + } + }; -const Header: React.FC = () => { return (
- CASTAD + ADO2
- + {loggedIn ? ( + + ) : ( + + )}
); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 884c7de..0a6fc47 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -57,7 +57,7 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome }) => const menuItems = [ { id: '대시보드', label: '대시보드', disabled: true, icon: }, { id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: }, - { id: '내 보관함', label: '내 보관함', disabled: true, icon: }, + { id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: true, icon: }, { id: '에셋 관리', label: '에셋 관리', disabled: true, icon: }, { id: '내 펜션', label: '내 펜션', disabled: true, icon: }, { id: '계정 설정', label: '계정 설정', disabled: true, icon: }, diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx index f413a32..fee0181 100755 --- a/src/pages/Analysis/AnalysisResultSection.tsx +++ b/src/pages/Analysis/AnalysisResultSection.tsx @@ -50,129 +50,114 @@ const formatReportText = (text: string): React.ReactNode[] => { }); }; -// 마크다운 report를 섹션별로 파싱 -const parseReport = (report: string) => { - if (!report || report.trim() === '') { - return []; - } +// 셀링 포인트 카드 타입 +interface SellingPointCard { + title: string; + items: string[]; +} - const sections: { title: string; content: string }[] = []; - const lines = report.split('\n'); - let currentTitle = ''; - let currentContent: string[] = []; - let hasMarkdownHeaders = report.includes('## '); +// facilities 배열을 셀링 포인트 카드 형태로 변환 +const parseSellingPoints = (facilities: string[]): SellingPointCard[] => { + // 기본 카테고리 정의 + const categories: SellingPointCard[] = [ + { title: '브랜드 컨셉', items: [] }, + { title: '프라이버시', items: [] }, + { title: '로컬 결합', items: [] }, + { title: '무드/비주얼', items: [] }, + { title: '편의/신뢰', items: [] }, + { title: '체류형 가치', items: [] }, + ]; - // 마크다운 헤더가 없는 경우 전체 텍스트를 하나의 섹션으로 반환 - if (!hasMarkdownHeaders) { - return [{ title: '분석 결과', content: report.trim() }]; - } - - lines.forEach((line) => { - if (line.startsWith('## ')) { - if (currentTitle || currentContent.length > 0) { - sections.push({ title: currentTitle, content: currentContent.join('\n').trim() }); - } - currentTitle = line.replace('## ', '').trim(); - currentContent = []; - } else if (!line.startsWith('# ')) { - currentContent.push(line); + // facilities를 카테고리에 분배 (최대 2개씩) + facilities.forEach((facility, idx) => { + const categoryIdx = Math.floor(idx / 2) % categories.length; + if (categories[categoryIdx].items.length < 2) { + categories[categoryIdx].items.push(facility); } }); - if (currentTitle || currentContent.length > 0) { - sections.push({ title: currentTitle, content: currentContent.join('\n').trim() }); - } - - return sections.filter(s => s.content && !s.title.includes('JSON')); + // 아이템이 있는 카테고리만 반환 + return categories.filter(cat => cat.items.length > 0); }; const AnalysisResultSection: React.FC = ({ onBack, onGenerate, data }) => { const { processed_info, marketing_analysis } = data; const tags = marketing_analysis.tags || []; const facilities = marketing_analysis.facilities || []; - const reportSections = parseReport(marketing_analysis.report); + const sellingPoints = parseSellingPoints(facilities); return (
- {/* Back Button */} -
- -
- - {/* Header */} -
-
- - - + {/* Header Area */} +
+ {/* Back Button */} +
+ +
+ + {/* Header */} +
+
+ + + +
+

브랜드 분석

+

+ 쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요. +

-

브랜드 분석

-

- 쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요. -

{/* Main Content Grid */}
- {/* Brand Identity */} + {/* Left: Brand Identity (Scrollable) */}
브랜드 정체성 AI 마케팅 분석 요약
-
-

{processed_info.customer_name}

-

{processed_info.region} · {processed_info.detail_region_info}

-
+
+
+

{processed_info.customer_name}

+

{processed_info.detail_region_info}

+
- {/* Marketing Analysis Summary */} -
-
- {reportSections.length === 0 ? ( -
- {marketing_analysis.report - ? formatReportText(marketing_analysis.report) - : '분석 결과가 없습니다.'} -
- ) : ( -
- {reportSections.map((section, idx) => ( -
- {section.title &&

{section.title}

} -
- {formatReportText(section.content)} -
-
- ))} -
- )} +
+ {marketing_analysis.report + ? formatReportText(marketing_analysis.report) + : '분석 결과가 없습니다.'}
-
- {/* Right Cards */} + {/* Right: Selling Points & Keywords (Fixed) */}
- {/* Main Selling Points (Facilities) */} -
- 주요 셀링 포인트 -
- {facilities.map((facility, idx) => ( - - {facility} - + {/* Main Selling Points */} +
+ 주요 셀링 포인트 +
+ {sellingPoints.map((point, idx) => ( +
+ {point.title} +
+ {point.items.map((item, itemIdx) => ( +

{item}

+ ))} +
+
))}
- {/* Recommended Target Keywords (Tags) */} -
+ {/* Recommended Target Keywords */} +
추천 타겟 키워드
{tags.map((tag, idx) => ( diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 20d994f..d57320d 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -6,12 +6,17 @@ import SoundStudioContent from './SoundStudioContent'; import CompletionContent from './CompletionContent'; import DashboardContent from './DashboardContent'; import BusinessSettingsContent from './BusinessSettingsContent'; -import { ImageItem } from '../../types/api'; +import UrlInputContent from './UrlInputContent'; +import LoadingSection from '../Analysis/LoadingSection'; +import AnalysisResultSection from '../Analysis/AnalysisResultSection'; +import { ImageItem, CrawlingResponse } from '../../types/api'; +import { crawlUrl } from '../../utils/api'; const WIZARD_STEP_KEY = 'castad_wizard_step'; const ACTIVE_ITEM_KEY = 'castad_active_item'; const SONG_TASK_ID_KEY = 'castad_song_task_id'; const IMAGE_TASK_ID_KEY = 'castad_image_task_id'; +const ANALYSIS_DATA_KEY = 'castad_analysis_data'; // 다른 컴포넌트에서 사용하는 storage key들 (초기화용) const SONG_GENERATION_KEY = 'castad_song_generation'; @@ -37,27 +42,82 @@ interface GenerationFlowProps { initialActiveItem?: string; initialImageList?: string[]; businessInfo?: BusinessInfo; + initialAnalysisData?: CrawlingResponse | null; } -const GenerationFlow: React.FC = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => { +// 위저드 단계: +// -2: URL 입력 +// -1: 로딩 (분석 중) +// 0: 브랜드 분석 결과 +// 1: 에셋 관리 (Asset Management) +// 2: 사운드 스튜디오 (Sound Studio) +// 3: 완료 (Completion) + +const GenerationFlow: React.FC = ({ + onHome, + initialActiveItem = '대시보드', + initialImageList = [], + businessInfo, + initialAnalysisData +}) => { // localStorage에서 저장된 상태 복원 const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY); const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY); const savedSongTaskId = localStorage.getItem(SONG_TASK_ID_KEY); const savedImageTaskId = localStorage.getItem(IMAGE_TASK_ID_KEY); + const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY); + + // 분석 데이터 파싱 + const parseAnalysisData = (): CrawlingResponse | null => { + if (initialAnalysisData) return initialAnalysisData; + if (!savedAnalysisData) return null; + try { + return JSON.parse(savedAnalysisData) as CrawlingResponse; + } catch { + return null; + } + }; const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem); - // 현재 위저드 단계 (0: Asset, 1: Sound Studio, 2: Completion) - const [wizardStep, setWizardStep] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0); + + // 초기 위저드 단계 결정 + const getInitialWizardStep = (): number => { + // 저장된 단계가 있으면 사용 + if (savedWizardStep !== null) { + return parseInt(savedWizardStep, 10); + } + // 분석 데이터가 있으면 에셋 관리(1)부터, 없으면 URL 입력(-2)부터 + const hasAnalysisData = initialAnalysisData || savedAnalysisData; + return hasAnalysisData ? 1 : -2; + }; + + const [wizardStep, setWizardStep] = useState(getInitialWizardStep()); const [songTaskId, setSongTaskId] = useState(savedSongTaskId); const [imageTaskId, setImageTaskId] = useState(savedImageTaskId); const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle'); const [videoGenerationProgress, setVideoGenerationProgress] = useState(0); + const [analysisData, setAnalysisData] = useState(parseAnalysisData()); + const [analysisError, setAnalysisError] = useState(null); + + // 현재 비즈니스 정보 (분석 데이터에서 가져오거나 prop에서 가져옴) + const currentBusinessInfo = analysisData?.processed_info || businessInfo; // URL 이미지를 ImageItem 형태로 변환하여 초기화 - const [imageList, setImageList] = useState( - initialImageList.map(url => ({ type: 'url', url })) - ); + const getInitialImageList = (): ImageItem[] => { + if (analysisData?.image_list && analysisData.image_list.length > 0) { + return analysisData.image_list.map(url => ({ type: 'url', url })); + } + return initialImageList.map(url => ({ type: 'url', url })); + }; + + const [imageList, setImageList] = useState(getInitialImageList()); + + // analysisData 변경 시 imageList 업데이트 + useEffect(() => { + if (analysisData?.image_list && analysisData.image_list.length > 0) { + setImageList(analysisData.image_list.map(url => ({ type: 'url', url }))); + } + }, [analysisData]); const handleRemoveImage = (index: number) => { setImageList(prev => { @@ -83,10 +143,12 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt // 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동 const handleHome = () => { clearAllProjectStorage(); - setWizardStep(0); + localStorage.removeItem(ANALYSIS_DATA_KEY); + setWizardStep(-2); setSongTaskId(null); setImageTaskId(null); - setImageList(initialImageList.map(url => ({ type: 'url', url }))); + setAnalysisData(null); + setImageList([]); onHome(); }; @@ -96,6 +158,50 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt localStorage.setItem(WIZARD_STEP_KEY, step.toString()); }; + // URL 분석 시작 + const handleStartAnalysis = async (url: string) => { + if (!url.trim()) return; + + goToWizardStep(-1); // 로딩 상태로 + setAnalysisError(null); + + try { + const data = await crawlUrl(url); + + // 기본값 보장 + if (data.marketing_analysis) { + data.marketing_analysis.tags = data.marketing_analysis.tags || []; + data.marketing_analysis.facilities = data.marketing_analysis.facilities || []; + data.marketing_analysis.report = data.marketing_analysis.report || ''; + } + 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 || []; + + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + goToWizardStep(0); // 브랜드 분석 결과로 + } catch (err) { + console.error('Crawling failed:', err); + const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.'; + setAnalysisError(errorMessage); + goToWizardStep(-2); // URL 입력으로 돌아가기 + } + }; + + // 브랜드 분석에서 콘텐츠 생성 클릭 + const handleAnalysisGenerate = () => { + goToWizardStep(1); // 에셋 관리로 + }; + + // 브랜드 분석에서 뒤로가기 + const handleAnalysisBack = () => { + goToWizardStep(-2); // URL 입력으로 + }; + // activeItem 변경 시 localStorage에 저장 useEffect(() => { localStorage.setItem(ACTIVE_ITEM_KEY, activeItem); @@ -104,7 +210,31 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt // 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링 const renderWizardContent = () => { switch (wizardStep) { + case -2: + // URL 입력 단계 + return ( + + ); + case -1: + // 로딩 단계 + return ; case 0: + // 브랜드 분석 결과 단계 + if (!analysisData) { + goToWizardStep(-2); + return null; + } + return ( + + ); + case 1: return ( { @@ -115,32 +245,32 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt setImageTaskId(taskId); localStorage.setItem(IMAGE_TASK_ID_KEY, taskId); - goToWizardStep(1); + goToWizardStep(2); }} imageList={imageList} onRemoveImage={handleRemoveImage} onAddImages={handleAddImages} /> ); - case 1: + case 2: return ( goToWizardStep(0)} + onBack={() => goToWizardStep(1)} onNext={(taskId: string) => { setSongTaskId(taskId); localStorage.setItem(SONG_TASK_ID_KEY, taskId); - goToWizardStep(2); + goToWizardStep(3); }} - businessInfo={businessInfo} + businessInfo={currentBusinessInfo} imageTaskId={imageTaskId} videoGenerationStatus={videoGenerationStatus} videoGenerationProgress={videoGenerationProgress} /> ); - case 2: + case 3: return ( goToWizardStep(1)} + onBack={() => goToWizardStep(2)} songTaskId={songTaskId} onVideoStatusChange={setVideoGenerationStatus} onVideoProgressChange={setVideoGenerationProgress} @@ -158,6 +288,10 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt case '비즈니스 설정': return ; case '새 프로젝트 만들기': + // 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시 + if (wizardStep === 0 || wizardStep === -1) { + return renderWizardContent(); + } return (
{renderWizardContent()} @@ -172,10 +306,34 @@ const GenerationFlow: React.FC = ({ onHome, initialActiveIt } }; + // 로딩 화면에서만 Sidebar 숨김 + const showSidebar = wizardStep !== -1; + + // 브랜드 분석(0)일 때는 전체 페이지 스크롤 + const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0; + // 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0) + const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis; + + // 브랜드 분석일 때는 높이 제한 없이 자연 스크롤 + if (isBrandAnalysis) { + return ( +
+ {showSidebar && ( + + )} +
+ {renderContent()} +
+
+ ); + } + return ( -
- -
+
+ {showSidebar && ( + + )} +
{renderContent()}
diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx new file mode 100644 index 0000000..78ce5dd --- /dev/null +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -0,0 +1,69 @@ + +import React, { useState } from 'react'; + +interface UrlInputContentProps { + onAnalyze: (url: string) => void; + error: string | null; +} + +const UrlInputContent: React.FC = ({ onAnalyze, error }) => { + const [url, setUrl] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (url.trim()) { + onAnalyze(url.trim()); + } + }; + + return ( +
+
+ {/* 아이콘 */} +
+ + + +
+ + {/* 제목 */} +

브랜드 분석

+

+ 쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요. +

+ + {/* URL 입력 폼 */} +
+
+ setUrl(e.target.value)} + placeholder="네이버 지도 URL을 입력하세요" + className="url-input-field" + /> + +
+ + {/* 에러 메시지 */} + {error && ( +

{error}

+ )} +
+ + {/* 안내 텍스트 */} +

+ 네이버 지도에서 업체 URL을 복사하여 붙여넣기 해주세요. +

+
+
+ ); +}; + +export default UrlInputContent; diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index 8ed1a35..14ccd42 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -50,6 +50,7 @@ const orbConfigs: OrbConfig[] = [ const HeroSection: React.FC = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => { const [url, setUrl] = useState(''); const [localError, setLocalError] = useState(''); + const [isFocused, setIsFocused] = useState(false); const orbRefs = useRef<(HTMLDivElement | null)[]>([]); const animationRefs = useRef([]); @@ -174,8 +175,8 @@ const HeroSection: React.FC = ({ onAnalyze, onNext, error: ext
{/* Logo Image */} CASTAD @@ -183,7 +184,7 @@ const HeroSection: React.FC = ({ onAnalyze, onNext, error: ext {/* Input Form */}
URL 입력 -
+
= ({ onAnalyze, onNext, error: ext setUrl(e.target.value); if (localError) setLocalError(''); }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} placeholder="https://www.castad.com" - className={`hero-input ${error ? 'error' : ''}`} + className={`hero-input ${url ? 'has-value' : ''}`} /> + {url && ( + + )}
URL에서 가져온 정보로 영상이 자동 생성됩니다. {error && ( diff --git a/src/pages/Login/LoginSection.tsx b/src/pages/Login/LoginSection.tsx index 92ea686..ae259f2 100755 --- a/src/pages/Login/LoginSection.tsx +++ b/src/pages/Login/LoginSection.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; +import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api'; interface LoginSectionProps { onBack: () => void; @@ -7,10 +8,66 @@ interface LoginSectionProps { } const LoginSection: React.FC = ({ onBack, onLogin }) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 { + await kakaoCallback(code); + + // 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('카카오 로그인에 실패했습니다. 다시 시도해주세요.'); + + // 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('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.'); + setIsLoading(false); + } + }; + return (
{/* Back Button */} - @@ -21,9 +78,20 @@ const LoginSection: React.FC = ({ onBack, onLogin }) => { CASTAD
+ {/* Error Message */} + {error && ( +
+ {error} +
+ )} + {/* Kakao Login Button */} -
diff --git a/src/types/api.ts b/src/types/api.ts index 04d454a..fa9fab3 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -180,3 +180,43 @@ export const LANGUAGE_MAP: Record = { 'ไทย': 'Thai', 'Tiếng Việt': 'Vietnamese', }; + +// 카카오 로그인 URL 응답 +export interface KakaoLoginUrlResponse { + auth_url: string; +} + +// 카카오 콜백 사용자 정보 +export interface KakaoCallbackUser { + id: number; + nickname: string; + email: string | null; + profile_image_url: string | null; + is_new_user: boolean; +} + +// 카카오 콜백 응답 (JWT 토큰) +export interface KakaoCallbackResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + user: KakaoCallbackUser; + redirect_url: string; +} + +// 토큰 갱신 응답 +export interface TokenRefreshResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +// 사용자 정보 응답 +export interface UserMeResponse { + id: number; + kakao_id: string; + nickname: string; + profile_image: string | null; + created_at: string; +} diff --git a/src/utils/api.ts b/src/utils/api.ts index 1d19496..f85b293 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -13,6 +13,10 @@ import { VideoDownloadResponse, ImageUrlItem, ImageUploadResponse, + KakaoLoginUrlResponse, + KakaoCallbackResponse, + TokenRefreshResponse, + UserMeResponse, } from '../types/api'; const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; @@ -344,3 +348,160 @@ export async function waitForVideoComplete( return poll(); } + +// ============================================ +// 카카오 인증 API +// ============================================ + +// 토큰 저장 키 +const ACCESS_TOKEN_KEY = 'castad_access_token'; +const REFRESH_TOKEN_KEY = 'castad_refresh_token'; + +// 토큰 저장 +export function saveTokens(accessToken: string, refreshToken: string) { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); +} + +// 토큰 가져오기 +export function getAccessToken(): string | null { + return localStorage.getItem(ACCESS_TOKEN_KEY); +} + +export function getRefreshToken(): string | null { + return localStorage.getItem(REFRESH_TOKEN_KEY); +} + +// 토큰 삭제 +export function clearTokens() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); +} + +// 인증 헤더 생성 +function getAuthHeader(): HeadersInit { + const token = getAccessToken(); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + +// 카카오 로그인 URL 획득 +export async function getKakaoLoginUrl(): Promise { + const response = await fetch(`${API_URL}/user/auth/kakao/login`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급) +export async function kakaoCallback(code: string): Promise { + const response = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: KakaoCallbackResponse = await response.json(); + + + // 토큰 저장 + saveTokens(data.access_token, data.refresh_token); + + return data; +} + +// Access Token 갱신 +export async function refreshAccessToken(): Promise { + const refreshToken = getRefreshToken(); + + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch(`${API_URL}/user/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + // 리프레시 토큰도 만료된 경우 토큰 삭제 + if (response.status === 401) { + clearTokens(); + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: TokenRefreshResponse = await response.json(); + + // 새 액세스 토큰 저장 + const currentRefreshToken = getRefreshToken(); + if (currentRefreshToken) { + saveTokens(data.access_token, currentRefreshToken); + } + + return data; +} + +// 로그아웃 +export async function logout(): Promise { + const response = await fetch(`${API_URL}/user/auth/logout`, { + method: 'POST', + headers: { + ...getAuthHeader(), + }, + }); + + // 응답과 관계없이 로컬 토큰 삭제 + clearTokens(); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } +} + +// 모든 기기에서 로그아웃 +export async function logoutAll(): Promise { + const response = await fetch(`${API_URL}/user/auth/logout/all`, { + method: 'POST', + headers: { + ...getAuthHeader(), + }, + }); + + // 응답과 관계없이 로컬 토큰 삭제 + clearTokens(); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } +} + +// 현재 사용자 정보 조회 +export async function getUserMe(): Promise { + const response = await fetch(`${API_URL}/user/auth/me`, { + method: 'GET', + headers: { + ...getAuthHeader(), + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} + +// 로그인 여부 확인 +export function isLoggedIn(): boolean { + return !!getAccessToken(); +}