From e66eca1acae5e9ff1149aa36b39a97ef51d0a617 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 3 Feb 2026 16:24:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=AC=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95,=20test=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=B6=94=EA=B0=80,=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.css | 334 +++++++++++++++++++ public/example_analysis.json | 180 ++++++++++ src/App.tsx | 8 + src/components/Sidebar.tsx | 52 ++- src/pages/Analysis/AnalysisResultSection.tsx | 222 +++++------- src/pages/Dashboard/GenerationFlow.tsx | 40 ++- src/pages/Dashboard/UrlInputContent.tsx | 34 +- src/pages/Landing/HeroSection.tsx | 35 +- src/types/api.ts | 10 +- src/utils/api.ts | 62 ++-- 10 files changed, 804 insertions(+), 173 deletions(-) create mode 100644 public/example_analysis.json diff --git a/index.css b/index.css index b0eaec6..b4ad224 100644 --- a/index.css +++ b/index.css @@ -745,6 +745,15 @@ height: 2.5rem; border-radius: var(--radius-full); border: 1px solid var(--color-border-gray-700); + object-fit: cover; +} + +.profile-avatar-default { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-bg-gray-700); + color: var(--color-text-gray-400); } .profile-name { @@ -2303,6 +2312,299 @@ background-color: #9B5DE5; } +/* ===================================================== + Brand Intelligence Page Typography (3단계 체계) + - Title: 24px (섹션 제목) + - Body: 16px (본문) + - Label: 14px (라벨, 태그) + ===================================================== */ + +/* 페이지 헤더 */ +.bi-page-header { + text-align: center; + margin-bottom: 48px; + padding: 0 16px; +} + +.bi-page-icon { + color: #2dd4bf; + margin-bottom: 20px; +} + +.bi-page-icon svg { + width: 40px; + height: 40px; +} + +.bi-page-title { + font-size: 32px; + font-weight: 700; + color: #FFFFFF; + margin-bottom: 16px; + letter-spacing: -0.02em; +} + +.bi-page-desc { + font-size: 16px; + color: #6AB0B3; + line-height: 1.6; + max-width: 600px; + margin: 0 auto; +} + +.bi-page-desc .highlight { + color: #2dd4bf; + font-weight: 600; +} + +/* 카드 공통 스타일 */ +.bi-card { + background-color: #01393B; + border-radius: 24px; + padding: 32px; + border: 1px solid rgba(106, 176, 179, 0.1); +} + +.bi-card-title { + font-size: 24px; + font-weight: 700; + color: #FFFFFF; + margin-bottom: 24px; + display: flex; + align-items: center; + gap: 12px; +} + +.bi-card-title svg { + width: 24px; + height: 24px; + color: #2dd4bf; +} + +/* 섹션 라벨 */ +.bi-section-label { + font-size: 14px; + font-weight: 600; + color: #2dd4bf; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +/* 브랜드명 (대형) */ +.bi-brand-name { + font-size: 32px; + font-weight: 700; + color: #FFFFFF; + margin-bottom: 12px; + letter-spacing: -0.02em; +} + +/* 주소/위치 정보 */ +.bi-location { + font-size: 16px; + color: #6AB0B3; + line-height: 1.5; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.bi-location svg { + flex-shrink: 0; + margin-top: 2px; +} + +/* 본문 텍스트 */ +.bi-body-text { + font-size: 16px; + color: rgba(255, 255, 255, 0.9); + line-height: 1.7; +} + +/* 서브 섹션 제목 */ +.bi-subsection-title { + font-size: 14px; + font-weight: 600; + color: #2dd4bf; + margin-bottom: 8px; +} + +/* 값/내용 강조 */ +.bi-value { + font-size: 16px; + font-weight: 600; + color: #FFFFFF; + line-height: 1.5; +} + +.bi-value-large { + font-size: 24px; + font-weight: 700; + color: #FFFFFF; + line-height: 1.4; +} + +/* 내부 카드/박스 */ +.bi-inner-box { + background-color: rgba(0, 34, 36, 0.5); + border-radius: 16px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.bi-inner-box-accent { + background: linear-gradient(to right, rgba(0, 34, 36, 0.5), rgba(1, 57, 59, 0.8)); + border-left: 4px solid #2dd4bf; +} + +/* 태그/뱃지 */ +.bi-tag { + font-size: 14px; + padding: 6px 12px; + background-color: rgba(45, 212, 191, 0.1); + color: #2dd4bf; + border-radius: 6px; + font-weight: 500; +} + +.bi-tag-outline { + font-size: 16px; + padding: 10px 20px; + background-color: transparent; + color: #FFFFFF; + border: 1px solid rgba(106, 176, 179, 0.3); + border-radius: 100px; +} + +.bi-tag-outline:hover { + border-color: rgba(45, 212, 191, 0.5); +} + +/* USP 카드 */ +.bi-usp-top { + background: linear-gradient(to right, rgba(45, 212, 191, 0.1), rgba(174, 114, 249, 0.1)); + border: 1px solid rgba(45, 212, 191, 0.3); + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; +} + +.bi-usp-item { + background-color: rgba(0, 34, 36, 0.4); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 20px; +} + +.bi-usp-category { + font-size: 13px; + font-weight: 500; + color: #6AB0B3; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.bi-usp-category-accent { + color: #2dd4bf; +} + +.bi-usp-description { + font-size: 15px; + font-weight: 400; + color: rgba(255, 255, 255, 0.7); + line-height: 1.5; +} + +.bi-usp-badge { + font-size: 14px; + padding: 4px 12px; + background-color: rgba(45, 212, 191, 0.2); + color: #2dd4bf; + border-radius: 4px; + font-weight: 600; +} + +.bi-usp-english-category { + font-size: 13px; + font-weight: 500; + color: #6AB0B3; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bi-usp-korean-category { + font-size: 20px; + font-weight: 700; + color: #FFFFFF; + margin-bottom: 6px; +} + +.bi-usp-score { + font-size: 24px; + font-weight: 700; + color: #6AB0B3; +} + +/* 페르소나 카드 */ +.bi-persona-name { + font-size: 16px; + font-weight: 700; + color: #FFFFFF; + margin-bottom: 4px; +} + +.bi-persona-age { + font-size: 14px; + color: #6AB0B3; +} + +.bi-persona-trigger { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + line-height: 1.5; + border-top: 1px solid rgba(255, 255, 255, 0.05); + padding-top: 12px; + margin-top: 12px; +} + +.bi-persona-trigger strong { + color: #2dd4bf; + font-weight: 600; +} + +/* 레이더 차트 라벨 */ +.bi-chart-label { + font-size: 14px; + fill: rgba(255, 255, 255, 0.9); + font-weight: 600; +} + +.bi-chart-sublabel { + font-size: 13px; + fill: rgba(255, 255, 255, 0.5); + font-weight: 500; +} + +/* 반응형 */ +@media (min-width: 768px) { + .bi-page-title { + font-size: 40px; + } + + .bi-page-desc { + font-size: 18px; + } + + .bi-brand-name { + font-size: 36px; + } +} + /* ===================================================== URL Input Content (GenerationFlow 내 URL 입력 단계) ===================================================== */ @@ -8780,3 +9082,35 @@ color: rgba(255, 255, 255, 0.5); text-align: center; } + +/* ===================================================== + Test Data Button (for development/testing) + ===================================================== */ +.test-data-button { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 20px; + background-color: rgba(174, 114, 249, 0.9); + color: #FFFFFF; + border: 1px solid rgba(174, 114, 249, 0.5); + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + z-index: 9999; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3); +} + +.test-data-button:hover { + background-color: rgba(174, 114, 249, 1); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(174, 114, 249, 0.4); +} + +.test-data-button:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} diff --git a/public/example_analysis.json b/public/example_analysis.json new file mode 100644 index 0000000..7c6c4b3 --- /dev/null +++ b/public/example_analysis.json @@ -0,0 +1,180 @@ +{ + "status": "completed", + "image_list": [ + "https://ldb-phinf.pstatic.net/20231014_144/169725663058516E0U_JPEG/IMG_3839.jpg", + "https://ldb-phinf.pstatic.net/20231014_157/1697256565852G12Xq_JPEG/IMG_4070.jpg", + "https://ldb-phinf.pstatic.net/20240322_45/1711068404867hanj8_PNG/20240322_092346.png", + "https://ldb-phinf.pstatic.net/20240627_69/1719454147456eolH4_JPEG/IMG_2544-%C7%E2%BB%F3%B5%CA-NR.jpg", + "https://ldb-phinf.pstatic.net/20240322_237/1711087704873wnpNu_PNG/20240322_092431.png", + "https://ldb-phinf.pstatic.net/20231014_52/169725663061836m1w_JPEG/IMG_3856.jpg", + "https://ldb-phinf.pstatic.net/20231014_80/1697256599519Ytcgo_JPEG/IMG_3187.jpg", + "https://ldb-phinf.pstatic.net/20231014_275/1697256612864OqGlm_JPEG/IMG_2120.jpg", + "https://ldb-phinf.pstatic.net/20240627_260/1719454147490SqEkU_JPEG/IMG_2879.jpg", + "https://ldb-phinf.pstatic.net/20231014_251/16972566308614us3m_JPEG/IMG_3623.jpg", + "https://ldb-phinf.pstatic.net/20231014_22/16972566306530DPWM_JPEG/IMG_2360.jpg", + "https://ldb-phinf.pstatic.net/20231014_76/1697256657970R0KNt_JPEG/DJI_0209.jpg", + "https://ldb-phinf.pstatic.net/20231014_40/1697256648019bVBjT_JPEG/IMG_2554.jpg", + "https://ldb-phinf.pstatic.net/20231014_198/1697256648321iXdGu_JPEG/IMG_2562.jpg", + "https://ldb-phinf.pstatic.net/20231014_139/1697256648096Ohoec_JPEG/IMG_2963.jpg", + "https://ldb-phinf.pstatic.net/20231014_111/1697256648088CSeh1_JPEG/IMG_3251.jpg", + "https://ldb-phinf.pstatic.net/20231014_150/1697256648156FWPtb_JPEG/IMG_3311.jpg", + "https://ldb-phinf.pstatic.net/20231014_20/16972566127576KqQi_JPEG/IMG_2040.jpg", + "https://ldb-phinf.pstatic.net/20231014_28/1697256612782hkd2c_JPEG/IMG_2042.jpg", + "https://ldb-phinf.pstatic.net/20231014_71/1697256612733xtlGx_JPEG/IMG_2072.jpg", + "https://ldb-phinf.pstatic.net/20231014_234/1697256612787eytkw_JPEG/IMG_2093.jpg", + "https://ldb-phinf.pstatic.net/20231014_134/16972566128455s63J_JPEG/IMG_2179.jpg", + "https://ldb-phinf.pstatic.net/20231014_95/1697256599366uh3xj_JPEG/IMG_3226.jpg", + "https://ldb-phinf.pstatic.net/20231014_50/1697256599494CUa3a_JPEG/IMG_3294.jpg", + "https://ldb-phinf.pstatic.net/20231014_47/1697256599453ss6Gi_JPEG/IMG_3317.jpg", + "https://ldb-phinf.pstatic.net/20231014_14/1697256599788JTSKc_JPEG/IMG_3335.jpg", + "https://ldb-phinf.pstatic.net/20231014_187/1697256579962BwFm5_JPEG/IMG_1960.jpg", + "https://ldb-phinf.pstatic.net/20231014_189/1697256579798uSb30_JPEG/IMG_1964.jpg", + "https://ldb-phinf.pstatic.net/20231014_121/1697256579860AVndh_JPEG/IMG_1974.jpg", + "https://ldb-phinf.pstatic.net/20231014_205/1697256579880zxdMI_JPEG/IMG_2012.jpg", + "https://ldb-phinf.pstatic.net/20231014_62/1697256564335BFax7_JPEG/IMG_2442.jpg", + "https://ldb-phinf.pstatic.net/20231014_196/16972565643745j50A_JPEG/IMG_2454.jpg", + "https://ldb-phinf.pstatic.net/20231014_168/1697256564383yhAnq_JPEG/IMG_2475.jpg", + "https://ldb-phinf.pstatic.net/20231014_73/1697256564344hzNX2_JPEG/IMG_2484.jpg", + "https://ldb-phinf.pstatic.net/20231014_173/1697256564424FoIA5_JPEG/IMG_2493.jpg", + "https://ldb-phinf.pstatic.net/20231014_71/1697256564399b7dt7_JPEG/IMG_2502.jpg", + "https://ldb-phinf.pstatic.net/20231014_146/1697256565062rPP8Y_JPEG/IMG_2512.jpg", + "https://ldb-phinf.pstatic.net/20231014_22/1697256565147ge98P_JPEG/IMG_2514.jpg", + "https://ldb-phinf.pstatic.net/20231014_88/16972565652873iu68_JPEG/IMG_2687.jpg", + "https://ldb-phinf.pstatic.net/20231014_47/16972565652481NSjB_JPEG/IMG_3018.jpg", + "https://ldb-phinf.pstatic.net/20231014_204/1697256565424iUyzx_JPEG/IMG_3277.jpg", + "https://ldb-phinf.pstatic.net/20231014_151/1697256565905GSqzA_JPEG/IMG_4067.jpg", + "https://ldb-phinf.pstatic.net/20231014_59/1697256543645lCsjL_JPEG/IMG_1877.jpg", + "https://ldb-phinf.pstatic.net/20231014_293/1697256543347F6XI2_JPEG/IMG_1879.jpg", + "https://ldb-phinf.pstatic.net/20231014_11/1697256543317khphi_JPEG/IMG_1891.jpg", + "https://ldb-phinf.pstatic.net/20231014_2/16972565432871No2O_JPEG/IMG_1912.jpg", + "https://ldb-phinf.pstatic.net/20231014_66/1697256543327u3AdT_JPEG/IMG_1921.jpg", + "https://ldb-phinf.pstatic.net/20231014_117/1697256544034UzHKY_JPEG/IMG_1927.jpg", + "https://ldb-phinf.pstatic.net/20231014_179/1697256543575ySMr4_JPEG/IMG_1930.jpg", + "https://ldb-phinf.pstatic.net/20231014_71/1697256544142DImfv_JPEG/IMG_1931.jpg", + "https://ldb-phinf.pstatic.net/20231014_66/1697256544018cjO7V_JPEG/IMG_1940.jpg", + "https://ldb-phinf.pstatic.net/20231014_167/1697256544176t77Fd_JPEG/IMG_1951.jpg", + "https://ldb-phinf.pstatic.net/20231014_97/1697256544331xjiDB_JPEG/IMG_2197.jpg", + "https://ldb-phinf.pstatic.net/20231014_192/1697256529172UV3zH_JPEG/IMG_2866.jpg", + "https://ldb-phinf.pstatic.net/20231014_33/1697256529097pvb0J_JPEG/IMG_2869.jpg", + "https://ldb-phinf.pstatic.net/20231014_120/1697256529062962wx_JPEG/IMG_3355.jpg" + ], + "image_count": 56, + "processed_info": { + "customer_name": "가평 도그랜드 애견 글림핑 카라반", + "region": "", + "detail_region_info": "경기 가평군 조종면 운악청계로 238-68" + }, + "marketing_analysis": { + "brand_identity": { + "location_feature_analysis": "경기 가평군 조종면 운악청계로 일대는 산·계곡 자연경관이 뛰어나 반려견과 야외 체류 콘텐츠를 만들기 좋은 입지입니다. 수도권에서 주말 단기 이동이 가능한 거리감으로 ‘가평 숏브레이크’ 수요를 흡수하기 유리합니다.", + "concept_scalability": "‘애견 글림핑 카라반’ 콘셉트는 숙박을 넘어 반려견 동반 액티비티(산책 코스, 운동장, 놀이 프로그램)와 펫 전용 어메니티/용품 판매로 경험 확장이 가능합니다. 시즌별 테마(불멍·BBQ·수영)와 패키지화로 재방문 동기를 구조화할 수 있습니다." + }, + "market_positioning": { + "category_definition": "가평 자연형 반려견 동반 글림핑·카라반", + "core_value": "반려견과 함께 ‘눈치 없이’ 즐기는 자유로운 아웃도어 휴식" + }, + "target_persona": [ + { + "persona": "주말 견캉스 커플/부부: 반려견 1마리와 근교 자연에서 쉬고 사진도 남기는 1~2박 선호", + "age": { + "min_age": 27, + "max_age": 39 + }, + "favor_target": [ + "반려견 동반 100% 가능", + "자연뷰 산책 동선", + "카라반/글림핑 감성 인테리어", + "바베큐·불멍 같은 야외 콘텐츠", + "깔끔한 침구·위생 관리" + ], + "decision_trigger": "반려견 동반 시설/동선이 명확하고, 불멍·BBQ 등 체류 콘텐츠가 한 번에 해결될 때 예약 확정" + }, + { + "persona": "펫프렌들리 친구 모임: 반려견과 함께 소규모로 모여 프라이빗하게 놀고 먹는 ‘모임형’ 여행", + "age": { + "min_age": 26, + "max_age": 42 + }, + "favor_target": [ + "단체 이용 가능 객실 구성", + "야외 바베큐 공간", + "밤 분위기 좋은 조명", + "사진 잘 나오는 포인트", + "주차/동선 편의" + ], + "decision_trigger": "BBQ 세팅 편의성과 야간 무드(조명·불멍) 콘텐츠 유무가 최종 선택 포인트" + }, + { + "persona": "액티브 펫러: 반려견과 함께 뛰어놀고 산책하며 ‘자연 체험’을 우선하는 고객", + "age": { + "min_age": 24, + "max_age": 45 + }, + "favor_target": [ + "자연 속 입지(산/계곡)", + "넓은 야외 공간", + "짧은 이동거리(수도권 근교)", + "반려견 스트레스 적은 환경", + "계절형 체험(물놀이/불멍)" + ], + "decision_trigger": "주변 산책/자연 환경의 설득력(사진·후기)과 반려견이 안전하게 뛰놀 수 있는 공간 확인 시 예약" + } + ], + "selling_points": [ + { + "english_category": "PET FRIENDLY", + "korean_category": "애견 동반", + "description": "반려견과 함께하는 견캉스", + "score": 95 + }, + { + "english_category": "LOCATION", + "korean_category": "입지 환경", + "description": "가평 자연 속 힐링 스테이", + "score": 88 + }, + { + "english_category": "BBQ PARTY", + "korean_category": "바베큐", + "description": "야외 바베큐로 완성되는 밤", + "score": 84 + }, + { + "english_category": "FIRE PIT", + "korean_category": "불멍", + "description": "불멍 한 번에 감성 충전", + "score": 82 + }, + { + "english_category": "NIGHT MOOD", + "korean_category": "야간 감성", + "description": "조명 아래 무드 있는 글램나잇", + "score": 78 + }, + { + "english_category": "PHOTO SPOT", + "korean_category": "포토 스팟", + "description": "반려견 인생샷 포인트 가득", + "score": 80 + }, + { + "english_category": "SHORT GETAWAY", + "korean_category": "숏브레이크", + "description": "주말 1박에 딱 좋은 근교", + "score": 76 + } + ], + "target_keywords": [ + "가평애견동반", + "가평글램핑", + "가평카라반", + "애견글램핑", + "반려견동반숙소", + "견캉스", + "서울근교여행", + "가평불멍", + "가평바베큐", + "가평감성숙소" + ] + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 98ba4fd..70760f4 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -303,6 +303,13 @@ const App: React.FC = () => { } }; + // 테스트 데이터로 브랜드 분석 페이지 이동 + const handleTestData = (data: CrawlingResponse) => { + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + setViewMode('analysis'); + }; + const handleToLogin = async () => { // 이미 로그인된 상태면 바로 generation_flow로 이동 if (isLoggedIn()) { @@ -406,6 +413,7 @@ const App: React.FC = () => { scrollToSection(1)} error={error} scrollProgress={scrollProgress} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b137257..d55cf4f 100755 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; +import { UserMeResponse } from '../types/api'; +import { logout } from '../utils/api'; interface SidebarItemProps { icon: React.ReactNode; @@ -29,11 +31,30 @@ interface SidebarProps { activeItem: string; onNavigate: (id: string) => void; onHome?: () => void; + userInfo?: UserMeResponse | null; + onLogout?: () => void; } -const Sidebar: React.FC = ({ activeItem, onNavigate, onHome }) => { +const Sidebar: React.FC = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => { const [isCollapsed, setIsCollapsed] = useState(false); const [isMobileOpen, setIsMobileOpen] = useState(false); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + // 로그아웃 처리 + const handleLogout = async () => { + if (isLoggingOut) return; + setIsLoggingOut(true); + try { + await logout(); + onLogout?.(); + } catch (error) { + console.error('Logout failed:', error); + // 에러가 나도 로컬 토큰은 이미 삭제됨, 홈으로 이동 + onLogout?.(); + } finally { + setIsLoggingOut(false); + } + }; useEffect(() => { const handleResize = () => { @@ -125,23 +146,36 @@ const Sidebar: React.FC = ({ activeItem, onNavigate, onHome }) =>
- Profile + {userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? ( + Profile + ) : ( +
+ + + + +
+ )} {!isCollapsed && (
-

username1234

+

{userInfo?.nickname || '사용자'}

)}
-
diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx index b44069f..e0b172a 100755 --- a/src/pages/Analysis/AnalysisResultSection.tsx +++ b/src/pages/Analysis/AnalysisResultSection.tsx @@ -15,14 +15,14 @@ const SparklesIcon = ({ className = '' }: { className?: string }) => ( ); const MapPinIcon = () => ( - + ); const TargetIcon = () => ( - + @@ -30,7 +30,7 @@ const TargetIcon = () => ( ); const UsersIcon = () => ( - + @@ -38,7 +38,7 @@ const UsersIcon = () => ( ); const LayoutGridIcon = () => ( - + @@ -47,7 +47,7 @@ const LayoutGridIcon = () => ( ); const ChartIcon = () => ( - + ); @@ -84,14 +84,14 @@ const AnimatedItem: React.FC = ({ children, index, baseDelay // 애니메이션 USP 아이템 컴포넌트 interface AnimatedUSPItemProps { - usp: { category: string; description: string; score: number }; + usp: SellingPoint; index: number; isTop?: boolean; } const AnimatedUSPItem: React.FC = ({ usp, index, isTop = false }) => { const [isVisible, setIsVisible] = useState(false); - const delay = index * 150; // 각 아이템마다 150ms 딜레이 + const delay = index * 150; useEffect(() => { const timer = setTimeout(() => { @@ -103,60 +103,51 @@ const AnimatedUSPItem: React.FC = ({ usp, index, isTop = f if (isTop) { return (
-
-
-
-
- {usp.category} - TOP -
-
{usp.description}
-
+
+ {usp.english_category} + TOP
+
{usp.korean_category}
+
{usp.description}
); } return (
-
-
{usp.category}
-
{usp.description}
-
+
{usp.english_category}
+
{usp.korean_category}
+
{usp.description}
); }; // 레이더 차트 컴포넌트 interface RadarChartProps { - data: { category: string; description: string; score: number }[]; + data: SellingPoint[]; size?: number; } const RadarChart: React.FC = ({ data, size = 280 }) => { - // 애니메이션을 위한 현재 점수 상태 (0에서 시작) const [animatedScores, setAnimatedScores] = useState(data.map(() => 0)); const [isAnimating, setIsAnimating] = useState(true); - // 애니메이션 효과 useEffect(() => { const targetScores = data.map(item => item.score); - const duration = 1500; // 1.5초 + const duration = 1500; const startTime = Date.now(); const animate = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); - - // easeOutCubic 이징 함수 적용 const easeProgress = 1 - Math.pow(1 - progress, 3); const newScores = targetScores.map(target => target * easeProgress); @@ -172,18 +163,14 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { requestAnimationFrame(animate); }, [data]); - // 라벨 공간을 위해 여유 공간 추가 const padding = 60; - const center = size / 2 + padding; // padding 만큼 오프셋 - const maxRadius = size / 2 - 20; // 차트 반지름 - const levels = 5; // 동심원 개수 - - // 각 축의 각도 계산 + const center = size / 2 + padding; + const maxRadius = size / 2 - 20; + const levels = 5; const angleStep = (2 * Math.PI) / data.length; - // 점수를 좌표로 변환 (0-100 점수를 반지름으로) const getPoint = (index: number, score: number) => { - const angle = angleStep * index - Math.PI / 2; // -90도에서 시작 (12시 방향) + const angle = angleStep * index - Math.PI / 2; const radius = (score / 100) * maxRadius; return { x: center + radius * Math.cos(angle), @@ -191,21 +178,18 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { }; }; - // 라벨 위치 계산 const getLabelPosition = (index: number) => { const angle = angleStep * index - Math.PI / 2; - const radius = maxRadius + 20; + const radius = maxRadius + 25; return { x: center + radius * Math.cos(angle), y: center + radius * Math.sin(angle), }; }; - // 애니메이션된 점수로 데이터 포인트 계산 const dataPoints = animatedScores.map((score, i) => getPoint(i, score)); const dataPath = dataPoints.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z'; - // 동심원 경로 생성 const levelPaths = Array.from({ length: levels }, (_, levelIndex) => { const levelRadius = ((levelIndex + 1) / levels) * maxRadius; const points = data.map((_, i) => { @@ -218,7 +202,6 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { return points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z'; }); - // 축 라인 경로 const axisLines = data.map((_, i) => { const angle = angleStep * i - Math.PI / 2; const endX = center + maxRadius * Math.cos(angle); @@ -226,7 +209,6 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { return `M ${center} ${center} L ${endX} ${endY}`; }); - // SVG 전체 크기 (padding 포함) const svgSize = size + padding * 2; return ( @@ -237,7 +219,6 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { viewBox={`0 0 ${svgSize} ${svgSize}`} style={{ overflow: 'visible' }} > - {/* 동심원 (레벨) */} {levelPaths.map((path, i) => ( = ({ data, size = 280 }) => { /> ))} - {/* 축 라인 */} {axisLines.map((path, i) => ( = ({ data, size = 280 }) => { /> ))} - {/* 데이터 영역 */} = ({ data, size = 280 }) => { }} /> - {/* 데이터 포인트 */} {dataPoints.map((point, i) => ( = ({ data, size = 280 }) => { /> ))} - {/* 라벨 (한글 설명 + 영어 카테고리) */} {data.map((item, i) => { const pos = getLabelPosition(i); const isLeft = pos.x < center - 10; @@ -302,29 +279,23 @@ const RadarChart: React.FC = ({ data, size = 280 }) => { return ( - {/* 한글 설명 (메인 라벨) */} - {item.description} + {item.korean_category} - {/* 영어 카테고리 (서브 라벨) */} - {item.category} + {item.english_category} ); @@ -345,7 +316,6 @@ const AnalysisResultSection: React.FC = ({ onBack, o const sellingPoints = marketing_analysis?.selling_points || []; const targetKeywords = marketing_analysis?.target_keywords || []; - // 컨테이너 기준으로 버튼 위치 계산 useEffect(() => { const updateButtonPosition = () => { if (containerRef.current) { @@ -357,7 +327,6 @@ const AnalysisResultSection: React.FC = ({ onBack, o updateButtonPosition(); window.addEventListener('resize', updateButtonPosition); - // MutationObserver로 사이드바 변화 감지 const observer = new MutationObserver(updateButtonPosition); observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['class'] }); @@ -367,14 +336,13 @@ const AnalysisResultSection: React.FC = ({ onBack, o }; }, []); - // 가장 높은 점수의 USP 찾기 const topUSP = sellingPoints.length > 0 ? [...sellingPoints].sort((a, b) => b.score - a.score)[0] : null; - return (
+ {/* Header */}
-
-
-
- -
+ {/* Page Header */} +
+
+
-

브랜드 인텔리전스

-

- AI 데이터 분석을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다. +

브랜드 인텔리전스

+

+ AI 데이터 분석을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다.

+ {/* Main Grid */}
{/* 왼쪽 컬럼 */}
{/* 브랜드 정체성 카드 */} -
-
-
- - 브랜드 정체성 - +
+
+ +
+ 브랜드 정체성
-

{processed_info?.customer_name || '브랜드명'}

-
+

{processed_info?.customer_name || '브랜드명'}

+ +

{processed_info?.detail_region_info || '주소 정보 없음'}

-

{processed_info?.region || ''}

+

{processed_info?.region || ''}

-
+
-

입지 특성 분석

-

{brandIdentity?.location_feature_analysis || '정보 없음'}

+
입지 특성 분석
+

{brandIdentity?.location_feature_analysis || '정보 없음'}

-

컨셉 확장성

-

{brandIdentity?.concept_scalability || '정보 없음'}

+
컨셉 확장성
+

{brandIdentity?.concept_scalability || '정보 없음'}

{/* 시장 포지셔닝 카드 */} -
-

+
+

시장 포지셔닝

-
+
-
- 핵심 가치 (Core Value) -
{marketPositioning?.core_value || '정보 없음'}
+
+
핵심 가치 (Core Value)
+
{marketPositioning?.core_value || '정보 없음'}
-
- - 카테고리 정의 - -
{marketPositioning?.category_definition || '정보 없음'}
+
+
카테고리 정의
+
{marketPositioning?.category_definition || '정보 없음'}
{/* 타겟 페르소나 카드 */} -
-

+
+

타겟 페르소나

- {targetPersonas.length === 0 &&

정보 없음

} + {targetPersonas.length === 0 &&

정보 없음

} {targetPersonas.map((persona: TargetPersona, idx: number) => ( -
-
-
- {persona.persona} -
-
- {persona.age.min_age}~{persona.age.max_age}세 -
+
+
+
{persona.persona}
+
{persona.age.min_age}~{persona.age.max_age}세
{persona.favor_target.length > 0 && ( -
+
{persona.favor_target.map((favor: string, i: number) => ( - - {favor} - + {favor} ))}
)} {persona.decision_trigger && ( -

- Trigger: {persona.decision_trigger} +

+ Trigger: {persona.decision_trigger}

)}
@@ -500,16 +457,14 @@ const AnalysisResultSection: React.FC = ({ onBack, o {/* 오른쪽 컬럼 */}
{/* 주요 셀링 포인트 카드 */} -
-
-

- 주요 셀링 포인트 (USP) -

-
+
+

+ 주요 셀링 포인트 (USP) +

{/* 레이더 차트 */} {sellingPoints.length > 0 && ( -
+
)} @@ -524,10 +479,10 @@ const AnalysisResultSection: React.FC = ({ onBack, o )} {/* 나머지 USP 리스트 */} -
- {sellingPoints.length === 0 &&

정보 없음

} +
+ {sellingPoints.length === 0 &&

정보 없음

} {sellingPoints - .filter((usp: SellingPoint) => usp.category !== topUSP?.category) + .filter((usp: SellingPoint) => usp.english_category !== topUSP?.english_category) .map((usp: SellingPoint, idx: number) => ( = ({ onBack, o
{/* 추천 타겟 키워드 */} -
-
-

추천 타겟 키워드

-
- {targetKeywords.length === 0 && 정보 없음} +
+
+

추천 타겟 키워드

+
+ {targetKeywords.length === 0 && 정보 없음} {targetKeywords.map((keyword: string, idx: number) => ( - - # {keyword} - + # {keyword} ))}
diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 673c880..6f59cea 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -11,8 +11,8 @@ import ADO2ContentsPage from './ADO2ContentsPage'; import MyInfoContent from './MyInfoContent'; import LoadingSection from '../Analysis/LoadingSection'; import AnalysisResultSection from '../Analysis/AnalysisResultSection'; -import { ImageItem, CrawlingResponse } from '../../types/api'; -import { crawlUrl, autocomplete, AutocompleteRequest } from '../../utils/api'; +import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api'; +import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api'; const WIZARD_STEP_KEY = 'castad_wizard_step'; const ACTIVE_ITEM_KEY = 'castad_active_item'; @@ -102,6 +102,30 @@ const GenerationFlow: React.FC = ({ const [videoGenerationProgress, setVideoGenerationProgress] = useState(0); const [analysisData, setAnalysisData] = useState(parseAnalysisData()); const [analysisError, setAnalysisError] = useState(null); + const [userInfo, setUserInfo] = useState(null); + + // 로그인 직후 사용자 정보 조회 + useEffect(() => { + const fetchUserInfo = async () => { + try { + const data = await getUserMe(); + setUserInfo(data); + } catch (error) { + console.error('Failed to fetch user info:', error); + } + }; + fetchUserInfo(); + }, []); + + // 로그아웃 핸들러 + const handleLogout = () => { + clearTokens(); + localStorage.removeItem('castad_view_mode'); + localStorage.removeItem('castad_analysis_data'); + localStorage.removeItem(WIZARD_STEP_KEY); + localStorage.removeItem(ACTIVE_ITEM_KEY); + window.location.href = '/'; + }; // 현재 비즈니스 정보 (분석 데이터에서 가져오거나 prop에서 가져옴) const currentBusinessInfo = analysisData?.processed_info || businessInfo; @@ -193,6 +217,13 @@ const GenerationFlow: React.FC = ({ } }; + // 테스트 데이터로 브랜드 분석 페이지 이동 + const handleTestData = (data: CrawlingResponse) => { + setAnalysisData(data); + localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data)); + goToWizardStep(0); // 브랜드 분석 결과로 + }; + // URL 분석 시작 const handleStartAnalysis = async (url: string) => { if (!url.trim()) return; @@ -269,6 +300,7 @@ const GenerationFlow: React.FC = ({ ); @@ -390,7 +422,7 @@ const GenerationFlow: React.FC = ({ return (
{showSidebar && ( - + )}
{renderContent()} @@ -402,7 +434,7 @@ const GenerationFlow: React.FC = ({ return (
{showSidebar && ( - + )}
{renderContent()} diff --git a/src/pages/Dashboard/UrlInputContent.tsx b/src/pages/Dashboard/UrlInputContent.tsx index 284a9ff..d68028e 100644 --- a/src/pages/Dashboard/UrlInputContent.tsx +++ b/src/pages/Dashboard/UrlInputContent.tsx @@ -1,16 +1,21 @@ import React, { useState, useRef, useCallback } from 'react'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api'; +import { CrawlingResponse } from '../../types/api'; type SearchType = 'url' | 'name'; +// 환경변수에서 테스트 모드 확인 +const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; + interface UrlInputContentProps { onAnalyze: (value: string, type?: SearchType) => void; onAutocomplete?: (data: AutocompleteRequest) => void; + onTestData?: (data: CrawlingResponse) => void; error: string | null; } -const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, error }) => { +const UrlInputContent: React.FC = ({ onAnalyze, onAutocomplete, onTestData, error }) => { const [inputValue, setInputValue] = useState(''); const [searchType, setSearchType] = useState('url'); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -18,6 +23,22 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); const [showAutocomplete, setShowAutocomplete] = useState(false); const [selectedItem, setSelectedItem] = useState(null); + const [isLoadingTest, setIsLoadingTest] = useState(false); + + // 테스트 데이터 로드 핸들러 + const handleTestData = async () => { + if (!onTestData) return; + setIsLoadingTest(true); + try { + const response = await fetch('/example_analysis.json'); + const data: CrawlingResponse = await response.json(); + onTestData(data); + } catch (err) { + console.error('테스트 데이터 로드 실패:', err); + } finally { + setIsLoadingTest(false); + } + }; const [highlightedIndex, setHighlightedIndex] = useState(-1); const debounceRef = useRef(null); const autocompleteRef = useRef(null); @@ -239,6 +260,17 @@ const UrlInputContent: React.FC = ({ onAnalyze, onAutocomp {getGuideText()}

+ + {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */} + {isTestPage && onTestData && ( + + )}
); }; diff --git a/src/pages/Landing/HeroSection.tsx b/src/pages/Landing/HeroSection.tsx index 88146bf..8d19d81 100755 --- a/src/pages/Landing/HeroSection.tsx +++ b/src/pages/Landing/HeroSection.tsx @@ -1,12 +1,17 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api'; +import { CrawlingResponse } from '../../types/api'; type SearchType = 'url' | 'name'; +// 환경변수에서 테스트 모드 확인 +const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true'; + interface HeroSectionProps { onAnalyze?: (value: string, type?: SearchType) => void; onAutocomplete?: (data: AutocompleteRequest) => void; + onTestData?: (data: CrawlingResponse) => void; onNext?: () => void; error?: string | null; scrollProgress?: number; // 0 ~ 1 (스크롤 진행률) @@ -51,11 +56,28 @@ const orbConfigs: OrbConfig[] = [ { id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 }, ]; -const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, onNext, error: externalError, scrollProgress = 0 }) => { +const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => { const [inputValue, setInputValue] = useState(''); const [searchType, setSearchType] = useState('url'); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [localError, setLocalError] = useState(''); + const [isLoadingTest, setIsLoadingTest] = useState(false); + + // 테스트 데이터 로드 핸들러 + const handleTestData = async () => { + if (!onTestData) return; + setIsLoadingTest(true); + try { + const response = await fetch('/example_analysis.json'); + const data: CrawlingResponse = await response.json(); + onTestData(data); + } catch (error) { + console.error('테스트 데이터 로드 실패:', error); + setLocalError('테스트 데이터를 불러오는데 실패했습니다.'); + } finally { + setIsLoadingTest(false); + } + }; const [isFocused, setIsFocused] = useState(false); const [autocompleteResults, setAutocompleteResults] = useState([]); const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false); @@ -447,6 +469,17 @@ const HeroSection: React.FC = ({ onAnalyze, onAutocomplete, on
+ + {/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */} + {isTestPage && onTestData && ( + + )}
); }; diff --git a/src/types/api.ts b/src/types/api.ts index e7efacc..229bf91 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -11,7 +11,8 @@ export interface TargetPersona { // 셀링 포인트 export interface SellingPoint { - category: string; + english_category: string; + korean_category: string; description: string; score: number; } @@ -238,8 +239,13 @@ export interface TokenRefreshResponse { export interface UserMeResponse { id: number; kakao_id: string; + email?: string; nickname: string; - profile_image: string | null; + profile_image_url: string | null; + thumbnail_image_url?: string | null; + is_active?: boolean; + is_admin?: boolean; + last_login_at?: string; created_at: string; } diff --git a/src/utils/api.ts b/src/utils/api.ts index 7a68824..a0b6757 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -431,19 +431,33 @@ function getAuthHeader(): HeadersInit { return token ? { 'Authorization': `Bearer ${token}` } : {}; } -// 토큰 갱신 중복 방지를 위한 플래그 -let isRefreshing = false; +// 토큰 갱신 중복 방지를 위한 Promise (싱글톤 패턴) let refreshPromise: Promise | null = null; +// 로그인 페이지로 리다이렉트 (토큰 만료 시) +function redirectToLogin() { + // 토큰 삭제 + clearTokens(); + // localStorage 정리 + localStorage.removeItem('castad_view_mode'); + localStorage.removeItem('castad_analysis_data'); + localStorage.removeItem('castad_wizard_step'); + localStorage.removeItem('castad_active_item'); + // 홈으로 리다이렉트 + window.location.href = '/'; +} + // 401 에러 시 자동으로 토큰 갱신 후 재요청하는 래퍼 함수 async function authenticatedFetch( url: string, options: RequestInit = {} ): Promise { - // 인증 헤더 추가 - const headers = { + // 인증 헤더 + 캐시 방지 헤더 추가 + const headers: HeadersInit = { ...options.headers, ...getAuthHeader(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', }; let response = await fetch(url, { ...options, headers }); @@ -452,26 +466,28 @@ async function authenticatedFetch( if (response.status === 401) { try { // 이미 갱신 중이면 기존 Promise 재사용 (중복 요청 방지) - if (!isRefreshing) { - isRefreshing = true; - refreshPromise = refreshAccessToken(); + // refreshPromise가 존재하면 재사용, 없으면 새로 생성 + if (!refreshPromise) { + refreshPromise = refreshAccessToken().finally(() => { + // 성공/실패 상관없이 Promise 초기화 (다음 갱신을 위해) + refreshPromise = null; + }); } await refreshPromise; - isRefreshing = false; - refreshPromise = null; // 새 토큰으로 재요청 - const newHeaders = { + const newHeaders: HeadersInit = { ...options.headers, ...getAuthHeader(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', }; response = await fetch(url, { ...options, headers: newHeaders }); } catch (refreshError) { - isRefreshing = false; - refreshPromise = null; - // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트할 수 있음 console.error('Token refresh failed:', refreshError); + // 토큰 갱신 실패 시 로그인 페이지로 리다이렉트 + redirectToLogin(); throw refreshError; } } @@ -531,32 +547,38 @@ export async function refreshAccessToken(): Promise { const refreshToken = getRefreshToken(); if (!refreshToken) { + console.error('[Auth] No refresh token available'); throw new Error('No refresh token available'); } + console.log('[Auth] Attempting to refresh access token...'); + const response = await fetch(`${API_URL}/user/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', }, body: JSON.stringify({ refresh_token: refreshToken }), }); if (!response.ok) { + console.error(`[Auth] Token refresh failed with status: ${response.status}`); // 리프레시 토큰도 만료된 경우 토큰 삭제 - if (response.status === 401) { + if (response.status === 401 || response.status === 403) { + console.log('[Auth] Refresh token expired, clearing tokens...'); clearTokens(); } - throw new Error(`HTTP error! status: ${response.status}`); + throw new Error(`Token refresh failed: ${response.status}`); } const data: TokenRefreshResponse = await response.json(); + console.log('[Auth] Token refresh successful'); - // 새 액세스 토큰 저장 - const currentRefreshToken = getRefreshToken(); - if (currentRefreshToken) { - saveTokens(data.access_token, currentRefreshToken); - } + // 새 액세스 토큰 저장 (리프레시 토큰도 새로 받았으면 함께 저장) + const newRefreshToken = (data as TokenRefreshResponse & { refresh_token?: string }).refresh_token; + saveTokens(data.access_token, newRefreshToken || refreshToken); return data; }