재로그인 버그 수정, test 버튼 추가, 브랜드 분석페이지 수정 .
parent
61892a5d93
commit
e66eca1aca
334
index.css
334
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
"가평애견동반",
|
||||
"가평글램핑",
|
||||
"가평카라반",
|
||||
"애견글램핑",
|
||||
"반려견동반숙소",
|
||||
"견캉스",
|
||||
"서울근교여행",
|
||||
"가평불멍",
|
||||
"가평바베큐",
|
||||
"가평감성숙소"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = () => {
|
|||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onTestData={handleTestData}
|
||||
onNext={() => scrollToSection(1)}
|
||||
error={error}
|
||||
scrollProgress={scrollProgress}
|
||||
|
|
|
|||
|
|
@ -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<SidebarProps> = ({ activeItem, onNavigate, onHome }) => {
|
||||
const Sidebar: React.FC<SidebarProps> = ({ 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<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
|
|||
|
||||
<div className="sidebar-footer">
|
||||
<div className={`profile-section ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<img
|
||||
src="https://picsum.photos/seed/user/100/100"
|
||||
alt="Profile"
|
||||
className="profile-avatar"
|
||||
/>
|
||||
{userInfo?.profile_image_url || userInfo?.thumbnail_image_url ? (
|
||||
<img
|
||||
src={userInfo.thumbnail_image_url || userInfo.profile_image_url || ''}
|
||||
alt="Profile"
|
||||
className="profile-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="profile-avatar profile-avatar-default">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="profile-name">username1234</p>
|
||||
<p className="profile-name">{userInfo?.nickname || '사용자'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}>
|
||||
<button
|
||||
className={`logout-btn ${isCollapsed ? 'collapsed' : ''}`}
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
{!isCollapsed && <span className="logout-btn-label">로그아웃</span>}
|
||||
{!isCollapsed && <span className="logout-btn-label">{isLoggingOut ? '로그아웃 중...' : '로그아웃'}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ const SparklesIcon = ({ className = '' }: { className?: string }) => (
|
|||
);
|
||||
|
||||
const MapPinIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s7-6.1 7-12a7 7 0 10-14 0c0 5.9 7 12 7 12z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TargetIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M22 12h-4M12 22v-4M2 12h4" />
|
||||
|
|
@ -30,7 +30,7 @@ const TargetIcon = () => (
|
|||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M16 11a4 4 0 10-8 0" />
|
||||
<path d="M4 20c0-3.3 3.6-6 8-6s8 2.7 8 6" />
|
||||
<circle cx="12" cy="7" r="3" />
|
||||
|
|
@ -38,7 +38,7 @@ const UsersIcon = () => (
|
|||
);
|
||||
|
||||
const LayoutGridIcon = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="7" />
|
||||
<rect x="14" y="3" width="7" height="7" />
|
||||
<rect x="14" y="14" width="7" height="7" />
|
||||
|
|
@ -47,7 +47,7 @@ const LayoutGridIcon = () => (
|
|||
);
|
||||
|
||||
const ChartIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 20V10M12 20V4M6 20v-6" />
|
||||
</svg>
|
||||
);
|
||||
|
|
@ -84,14 +84,14 @@ const AnimatedItem: React.FC<AnimatedItemProps> = ({ children, index, baseDelay
|
|||
|
||||
// 애니메이션 USP 아이템 컴포넌트
|
||||
interface AnimatedUSPItemProps {
|
||||
usp: { category: string; description: string; score: number };
|
||||
usp: SellingPoint;
|
||||
index: number;
|
||||
isTop?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedUSPItem: React.FC<AnimatedUSPItemProps> = ({ 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<AnimatedUSPItemProps> = ({ usp, index, isTop = f
|
|||
if (isTop) {
|
||||
return (
|
||||
<div
|
||||
className={`mb-6 p-5 rounded-xl bg-gradient-to-r from-brand-accent/10 to-brand-purple/10 border border-brand-accent/30 relative overflow-hidden transition-all duration-500 ${
|
||||
className={`bi-usp-top transition-all duration-500 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-brand-accent/5 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="relative z-10 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-brand-accent font-bold uppercase tracking-wider">{usp.category}</span>
|
||||
<span className="text-xs bg-brand-accent/20 text-brand-accent px-2 py-0.5 rounded font-semibold">TOP</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">{usp.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="bi-usp-category bi-usp-category-accent">{usp.english_category}</span>
|
||||
<span className="bi-usp-badge">TOP</span>
|
||||
</div>
|
||||
<div className="bi-usp-korean-category">{usp.korean_category}</div>
|
||||
<div className="bi-usp-description">{usp.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-4 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-all duration-500 ${
|
||||
className={`bi-usp-item transition-all duration-500 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight mb-1">{usp.category}</div>
|
||||
<div className="text-base font-bold text-white">{usp.description}</div>
|
||||
</div>
|
||||
<div className="bi-usp-english-category mb-2">{usp.english_category}</div>
|
||||
<div className="bi-usp-korean-category">{usp.korean_category}</div>
|
||||
<div className="bi-usp-description">{usp.description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 레이더 차트 컴포넌트
|
||||
interface RadarChartProps {
|
||||
data: { category: string; description: string; score: number }[];
|
||||
data: SellingPoint[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
||||
// 애니메이션을 위한 현재 점수 상태 (0에서 시작)
|
||||
const [animatedScores, setAnimatedScores] = useState<number[]>(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<RadarChartProps> = ({ 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<RadarChartProps> = ({ 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<RadarChartProps> = ({ 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<RadarChartProps> = ({ 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<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{/* 동심원 (레벨) */}
|
||||
{levelPaths.map((path, i) => (
|
||||
<path
|
||||
key={`level-${i}`}
|
||||
|
|
@ -248,7 +229,6 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 축 라인 */}
|
||||
{axisLines.map((path, i) => (
|
||||
<path
|
||||
key={`axis-${i}`}
|
||||
|
|
@ -259,7 +239,6 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 데이터 영역 */}
|
||||
<path
|
||||
d={dataPath}
|
||||
fill="rgba(45, 212, 191, 0.2)"
|
||||
|
|
@ -270,7 +249,6 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* 데이터 포인트 */}
|
||||
{dataPoints.map((point, i) => (
|
||||
<circle
|
||||
key={`point-${i}`}
|
||||
|
|
@ -283,7 +261,6 @@ const RadarChart: React.FC<RadarChartProps> = ({ 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<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
|
||||
return (
|
||||
<g key={`label-${i}`}>
|
||||
{/* 한글 설명 (메인 라벨) */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
fill="rgba(255,255,255,0.9)"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
className="bi-chart-label"
|
||||
>
|
||||
{item.description}
|
||||
{item.korean_category}
|
||||
</text>
|
||||
{/* 영어 카테고리 (서브 라벨) */}
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
dy={dy + 12}
|
||||
dy={dy + 16}
|
||||
textAnchor={textAnchor}
|
||||
fill="rgba(255,255,255,0.4)"
|
||||
fontSize="8"
|
||||
fontWeight="400"
|
||||
className="bi-chart-sublabel"
|
||||
>
|
||||
{item.category}
|
||||
{item.english_category}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
|
|
@ -345,7 +316,6 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ 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<AnalysisResultSectionProps> = ({ 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<AnalysisResultSectionProps> = ({ onBack, o
|
|||
};
|
||||
}, []);
|
||||
|
||||
// 가장 높은 점수의 USP 찾기
|
||||
const topUSP = sellingPoints.length > 0
|
||||
? [...sellingPoints].sort((a, b) => b.score - a.score)[0]
|
||||
: null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
||||
{/* Header */}
|
||||
<div className="asset-header">
|
||||
<button onClick={onBack} className="btn-back-new">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
|
@ -384,110 +352,99 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-10 px-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="relative">
|
||||
<SparklesIcon className="text-brand-accent w-8 h-8 animate-pulse relative z-10" />
|
||||
</div>
|
||||
{/* Page Header */}
|
||||
<div className="bi-page-header">
|
||||
<div className="bi-page-icon">
|
||||
<SparklesIcon className="w-10 h-10 animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white">브랜드 인텔리전스</h1>
|
||||
<p className="text-brand-muted text-lg max-w-xl mx-auto">
|
||||
<span className="text-brand-accent font-semibold">AI 데이터 분석</span>을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다.
|
||||
<h1 className="bi-page-title">브랜드 인텔리전스</h1>
|
||||
<p className="bi-page-desc">
|
||||
<span className="highlight">AI 데이터 분석</span>을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div ref={containerRef} className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 왼쪽 컬럼 */}
|
||||
<div className="space-y-6">
|
||||
{/* 브랜드 정체성 카드 */}
|
||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500">
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-brand-accent font-bold text-sm uppercase tracking-wider flex items-center gap-2">
|
||||
<LayoutGridIcon /> 브랜드 정체성
|
||||
</span>
|
||||
<div className="bi-card relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-[#2dd4bf] to-[#AE72F9]"></div>
|
||||
|
||||
<div className="bi-section-label mb-4">
|
||||
<LayoutGridIcon /> 브랜드 정체성
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{processed_info?.customer_name || '브랜드명'}</h2>
|
||||
<div className="flex items-start gap-2 text-brand-muted text-sm mb-6">
|
||||
<h2 className="bi-brand-name">{processed_info?.customer_name || '브랜드명'}</h2>
|
||||
|
||||
<div className="bi-location mb-6">
|
||||
<MapPinIcon />
|
||||
<div>
|
||||
<p>{processed_info?.detail_region_info || '주소 정보 없음'}</p>
|
||||
<p className="opacity-70">{processed_info?.region || ''}</p>
|
||||
<p style={{ opacity: 0.7, marginTop: '4px' }}>{processed_info?.region || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5">
|
||||
<div className="space-y-6 border-t border-white/5 pt-6">
|
||||
<AnimatedItem index={0} baseDelay={100}>
|
||||
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">입지 특성 분석</h3>
|
||||
<p className="text-sm opacity-90">{brandIdentity?.location_feature_analysis || '정보 없음'}</p>
|
||||
<div className="bi-subsection-title">입지 특성 분석</div>
|
||||
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || '정보 없음'}</p>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={100}>
|
||||
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">컨셉 확장성</h3>
|
||||
<p className="text-sm opacity-90">{brandIdentity?.concept_scalability || '정보 없음'}</p>
|
||||
<div className="bi-subsection-title">컨셉 확장성</div>
|
||||
<p className="bi-body-text">{brandIdentity?.concept_scalability || '정보 없음'}</p>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시장 포지셔닝 카드 */}
|
||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
||||
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
<TargetIcon /> 시장 포지셔닝
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-4">
|
||||
<AnimatedItem index={0} baseDelay={200}>
|
||||
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
|
||||
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
||||
<div className="font-semibold text-white">{marketPositioning?.core_value || '정보 없음'}</div>
|
||||
<div className="bi-inner-box bi-inner-box-accent">
|
||||
<div className="bi-subsection-title">핵심 가치 (Core Value)</div>
|
||||
<div className="bi-value">{marketPositioning?.core_value || '정보 없음'}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={200}>
|
||||
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
|
||||
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
|
||||
카테고리 정의
|
||||
</span>
|
||||
<div className="font-bold text-lg text-white">{marketPositioning?.category_definition || '정보 없음'}</div>
|
||||
<div className="bi-inner-box">
|
||||
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>카테고리 정의</div>
|
||||
<div className="bi-value-large">{marketPositioning?.category_definition || '정보 없음'}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 페르소나 카드 */}
|
||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
||||
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
<UsersIcon /> 타겟 페르소나
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{targetPersonas.length === 0 && <p className="text-sm text-brand-muted">정보 없음</p>}
|
||||
{targetPersonas.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</p>}
|
||||
{targetPersonas.map((persona: TargetPersona, idx: number) => (
|
||||
<AnimatedItem key={idx} index={idx} baseDelay={300}>
|
||||
<div className="p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group">
|
||||
<div className="mb-3">
|
||||
<div className="font-bold text-white group-hover:text-brand-accent transition-colors mb-1">
|
||||
{persona.persona}
|
||||
</div>
|
||||
<div className="text-xs text-brand-muted">
|
||||
{persona.age.min_age}~{persona.age.max_age}세
|
||||
</div>
|
||||
<div className="bi-inner-box">
|
||||
<div className="mb-4">
|
||||
<div className="bi-persona-name">{persona.persona}</div>
|
||||
<div className="bi-persona-age">{persona.age.min_age}~{persona.age.max_age}세</div>
|
||||
</div>
|
||||
|
||||
{persona.favor_target.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{persona.favor_target.map((favor: string, i: number) => (
|
||||
<span
|
||||
key={i}
|
||||
className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium"
|
||||
>
|
||||
{favor}
|
||||
</span>
|
||||
<span key={i} className="bi-tag">{favor}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{persona.decision_trigger && (
|
||||
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
|
||||
<span className="text-brand-muted font-semibold">Trigger:</span> {persona.decision_trigger}
|
||||
<p className="bi-persona-trigger">
|
||||
<strong>Trigger:</strong> {persona.decision_trigger}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -500,16 +457,14 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
{/* 오른쪽 컬럼 */}
|
||||
<div className="space-y-6">
|
||||
{/* 주요 셀링 포인트 카드 */}
|
||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
|
||||
<div className="flex justify-between items-center mb-6 z-10">
|
||||
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<ChartIcon /> 주요 셀링 포인트 (USP)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bi-card min-h-[500px] flex flex-col">
|
||||
<h3 className="bi-card-title mb-6">
|
||||
<ChartIcon /> 주요 셀링 포인트 (USP)
|
||||
</h3>
|
||||
|
||||
{/* 레이더 차트 */}
|
||||
{sellingPoints.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-8">
|
||||
<RadarChart data={sellingPoints} size={320} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -524,10 +479,10 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
)}
|
||||
|
||||
{/* 나머지 USP 리스트 */}
|
||||
<div className="space-y-3 z-10 flex-1">
|
||||
{sellingPoints.length === 0 && <p className="text-sm text-brand-muted">정보 없음</p>}
|
||||
<div className="space-y-4 flex-1">
|
||||
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</p>}
|
||||
{sellingPoints
|
||||
.filter((usp: SellingPoint) => usp.category !== topUSP?.category)
|
||||
.filter((usp: SellingPoint) => usp.english_category !== topUSP?.english_category)
|
||||
.map((usp: SellingPoint, idx: number) => (
|
||||
<AnimatedUSPItem
|
||||
key={idx}
|
||||
|
|
@ -542,18 +497,13 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
</div>
|
||||
|
||||
{/* 추천 타겟 키워드 */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 mt-8">
|
||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
|
||||
<h3 className="text-xl font-bold mb-6 text-white">추천 타겟 키워드</h3>
|
||||
<div className="flex flex-wrap gap-3 relative z-10">
|
||||
{targetKeywords.length === 0 && <span className="text-sm text-brand-muted">정보 없음</span>}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 mt-10">
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">추천 타겟 키워드</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</span>}
|
||||
{targetKeywords.map((keyword: string, idx: number) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-5 py-2.5 rounded-full bg-brand-card border border-brand-muted/30 text-white text-sm hover:border-brand-accent/50 transition-colors"
|
||||
>
|
||||
# {keyword}
|
||||
</span>
|
||||
<span key={idx} className="bi-tag-outline"># {keyword}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<GenerationFlowProps> = ({
|
|||
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(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<GenerationFlowProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
||||
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<GenerationFlowProps> = ({
|
|||
<UrlInputContent
|
||||
onAnalyze={handleStartAnalysis}
|
||||
onAutocomplete={handleAutocomplete}
|
||||
onTestData={handleTestData}
|
||||
error={analysisError}
|
||||
/>
|
||||
);
|
||||
|
|
@ -390,7 +422,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
return (
|
||||
<div className="analysis-page-wrapper">
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
||||
)}
|
||||
<main className="analysis-page-main">
|
||||
{renderContent()}
|
||||
|
|
@ -402,7 +434,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
return (
|
||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} />
|
||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
||||
)}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-y-auto' : 'h-full overflow-hidden'}`}>
|
||||
{renderContent()}
|
||||
|
|
|
|||
|
|
@ -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<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, error }) => {
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onTestData, error }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [searchType, setSearchType] = useState<SearchType>('url');
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
|
@ -18,6 +23,22 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(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<number>(-1);
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const autocompleteRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -239,6 +260,17 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
|||
{getGuideText()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
||||
{isTestPage && onTestData && (
|
||||
<button
|
||||
onClick={handleTestData}
|
||||
disabled={isLoadingTest}
|
||||
className="test-data-button"
|
||||
>
|
||||
{isLoadingTest ? '로딩 중...' : '테스트 데이터'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [searchType, setSearchType] = useState<SearchType>('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<AccommodationSearchItem[]>([]);
|
||||
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
|
||||
|
|
@ -447,6 +469,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
|||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
||||
{isTestPage && onTestData && (
|
||||
<button
|
||||
onClick={handleTestData}
|
||||
disabled={isLoadingTest}
|
||||
className="test-data-button"
|
||||
>
|
||||
{isLoadingTest ? '로딩 중...' : '테스트 데이터'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -431,19 +431,33 @@ function getAuthHeader(): HeadersInit {
|
|||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 토큰 갱신 중복 방지를 위한 플래그
|
||||
let isRefreshing = false;
|
||||
// 토큰 갱신 중복 방지를 위한 Promise (싱글톤 패턴)
|
||||
let refreshPromise: Promise<TokenRefreshResponse> | 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<Response> {
|
||||
// 인증 헤더 추가
|
||||
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<TokenRefreshResponse> {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue