카카오 로그인 및 비로그인 플로우 적용 .
parent
2acf4e2cb6
commit
662b6b80bc
|
|
@ -6,7 +6,9 @@
|
|||
"Bash(rmdir:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(python3:*)"
|
||||
"Bash(python3:*)",
|
||||
"mcp__figma__get_figma_data",
|
||||
"mcp__figma__download_figma_images"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
710
index.css
710
index.css
|
|
@ -1821,87 +1821,108 @@
|
|||
Analysis Result Page Components
|
||||
===================================================== */
|
||||
|
||||
/* Analysis Container */
|
||||
/* Analysis Container - 높이 제한 없음, 자연 스크롤 */
|
||||
.analysis-container {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
color: var(--color-text-white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
background-color: var(--color-bg-dark);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: #002224;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 100px; /* 고정 버튼 영역 확보 */
|
||||
}
|
||||
|
||||
/* Header Area (Back Button + Title) */
|
||||
.analysis-header-area {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-container {
|
||||
padding: 2rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analysis-container {
|
||||
padding: 2.5rem 5%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.analysis-container {
|
||||
/* 피그마: 1440px 화면에서 양쪽 200px 패딩 = 약 13.9% */
|
||||
padding: 2.5rem calc((100% - 1040px) / 2);
|
||||
.analysis-header-area {
|
||||
padding: 8px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Analysis Header */
|
||||
.analysis-header {
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-header {
|
||||
margin-bottom: 2rem;
|
||||
padding: 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-icon {
|
||||
color: var(--color-purple);
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.analysis-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Analysis Grid */
|
||||
.analysis-grid {
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
max-width: 1440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand Identity Card */
|
||||
@media (min-width: 1024px) {
|
||||
.analysis-grid {
|
||||
flex-direction: row;
|
||||
gap: 40px;
|
||||
padding: 0 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.analysis-grid {
|
||||
padding: 0 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.analysis-grid {
|
||||
padding: 0 180px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Brand Identity Card - 왼쪽 */
|
||||
.brand-identity-card {
|
||||
background-color: #01393B;
|
||||
border-radius: 40px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
|
@ -1913,8 +1934,15 @@
|
|||
|
||||
@media (min-width: 768px) {
|
||||
.brand-identity-card {
|
||||
border-radius: 40px;
|
||||
padding: 32px;
|
||||
max-height: 453px;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.brand-identity-card {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1922,152 +1950,86 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-content {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #E5F1F2;
|
||||
letter-spacing: -0.006em;
|
||||
margin-bottom: 0;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-name {
|
||||
font-size: 32px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-location {
|
||||
color: #6AB0B3;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.006em;
|
||||
margin-bottom: 0;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-location {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: #6AB0B3;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Report Section */
|
||||
.report-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.report-toggle {
|
||||
font-size: 14px;
|
||||
color: #6AB0B3;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.report-toggle:hover {
|
||||
color: #AE72F9;
|
||||
}
|
||||
|
||||
/* Report Content */
|
||||
.report-content {
|
||||
color: #E5F1F2;
|
||||
font-size: 17px;
|
||||
color: #CEE5E6;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.006em;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.report-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.report-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.report-content::-webkit-scrollbar-thumb {
|
||||
background: #379599;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.report-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4AABAF;
|
||||
}
|
||||
|
||||
.report-section-title {
|
||||
color: var(--color-mint);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Image Preview */
|
||||
.image-preview-section {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.image-preview-title {
|
||||
color: #6AB0B3;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-dark);
|
||||
}
|
||||
|
||||
.image-preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.image-preview-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.image-preview-more {
|
||||
color: var(--color-text-gray-500);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: 0.5rem;
|
||||
text-align: center;
|
||||
@media (min-width: 768px) {
|
||||
.report-content {
|
||||
font-size: 17px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right Side Cards Container */
|
||||
|
|
@ -2076,88 +2038,346 @@
|
|||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-height: 453px;
|
||||
}
|
||||
|
||||
/* Feature Card for Analysis Page (Selling Points & Keywords) */
|
||||
@media (min-width: 768px) {
|
||||
.analysis-cards-column {
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.analysis-cards-column {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Feature Card for Analysis Page */
|
||||
.analysis-cards-column .feature-card {
|
||||
background-color: #01393B;
|
||||
border-radius: 40px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-cards-column .feature-card {
|
||||
border-radius: 40px;
|
||||
padding: 32px;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selling Points Card */
|
||||
.selling-points-card {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Selling Points Grid - 2열 그리드 */
|
||||
.selling-points-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.selling-points-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selling Point Item */
|
||||
.selling-point-item {
|
||||
background-color: #034A4D;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.selling-point-item {
|
||||
padding: 24px 20px;
|
||||
gap: 16px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 첫 번째 행 */
|
||||
.selling-point-item:nth-child(1) {
|
||||
border-radius: 16px 0 0 0;
|
||||
}
|
||||
.selling-point-item:nth-child(2) {
|
||||
border-radius: 0 16px 0 0;
|
||||
}
|
||||
|
||||
/* 마지막 행 */
|
||||
.selling-point-item:nth-last-child(2):nth-child(odd) {
|
||||
border-radius: 0 0 0 16px;
|
||||
}
|
||||
.selling-point-item:nth-last-child(1):nth-child(even) {
|
||||
border-radius: 0 0 16px 0;
|
||||
}
|
||||
|
||||
/* 홀수 개일 때 마지막 아이템 */
|
||||
.selling-point-item:last-child:nth-child(odd) {
|
||||
border-radius: 0 0 0 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.selling-point-title {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #6AB0B3;
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.selling-point-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.selling-point-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.selling-point-content {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.selling-point-content p {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #CEE5E6;
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.selling-point-content p {
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keywords Card */
|
||||
.keywords-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tags Wrapper */
|
||||
.tags-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
align-content: flex-start;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tags-wrapper::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tags-wrapper::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tags-wrapper::-webkit-scrollbar-thumb {
|
||||
background: #379599;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tags-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: #4AABAF;
|
||||
}
|
||||
|
||||
/* Feature Tag */
|
||||
.feature-tag {
|
||||
padding: 8px 16px;
|
||||
background-color: #046266;
|
||||
border-radius: 999px;
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Analysis Bottom Button */
|
||||
.analysis-bottom {
|
||||
width: 100%;
|
||||
max-width: 1040px;
|
||||
margin-top: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.analysis-bottom {
|
||||
padding-bottom: 1.5rem;
|
||||
.tags-wrapper {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Feature Tag - pill 형태 */
|
||||
.feature-tag {
|
||||
padding: 6px 12px;
|
||||
background-color: #034A4D;
|
||||
border-radius: 999px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #CEE5E6;
|
||||
letter-spacing: -0.006em;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.feature-tag {
|
||||
padding: 8px 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Analysis Bottom Button - 화면 하단 고정 */
|
||||
.analysis-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(to top, #002224 60%, transparent);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Sidebar가 있을 때 버튼 위치 조정 (md breakpoint) */
|
||||
@media (min-width: 768px) {
|
||||
.analysis-bottom {
|
||||
left: 280px;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.analysis-bottom .btn-primary {
|
||||
width: 160px;
|
||||
height: 48px;
|
||||
padding: 10px 24px;
|
||||
background-color: #AE72F9;
|
||||
border-radius: 999px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: -0.006em;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
box-shadow: 0 4px 20px rgba(174, 114, 249, 0.4);
|
||||
}
|
||||
|
||||
.analysis-bottom .btn-primary:hover {
|
||||
background-color: #9B5DE5;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
URL Input Content (GenerationFlow 내 URL 입력 단계)
|
||||
===================================================== */
|
||||
|
||||
.url-input-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #002224;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.url-input-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.url-input-icon {
|
||||
color: #AE72F9;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.url-input-title {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #E5F1F2;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.url-input-subtitle {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9BCACC;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0 0 40px 0;
|
||||
}
|
||||
|
||||
.url-input-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.url-input-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.url-input-field {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
background-color: #01393B;
|
||||
border: 1px solid #034A4D;
|
||||
border-radius: 12px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 16px;
|
||||
color: #E5F1F2;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.url-input-field::placeholder {
|
||||
color: #6AB0B3;
|
||||
}
|
||||
|
||||
.url-input-field:focus {
|
||||
border-color: #AE72F9;
|
||||
}
|
||||
|
||||
.url-input-button {
|
||||
padding: 16px 32px;
|
||||
background-color: #AE72F9;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.url-input-button:hover:not(:disabled) {
|
||||
background-color: #9B5DE5;
|
||||
}
|
||||
|
||||
.url-input-button:disabled {
|
||||
background-color: #4A5568;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.url-input-error {
|
||||
color: #F56565;
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.url-input-guide {
|
||||
font-family: 'Pretendard', sans-serif;
|
||||
font-size: 13px;
|
||||
color: #6AB0B3;
|
||||
margin: 24px 0 0 0;
|
||||
}
|
||||
|
||||
/* =====================================================
|
||||
Landing Page Components
|
||||
===================================================== */
|
||||
|
|
@ -2322,10 +2542,23 @@
|
|||
background-color: #ffffff;
|
||||
border-radius: 999px;
|
||||
padding: 11px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-input-wrapper.focused {
|
||||
box-shadow: 0 0 0 4px #379599;
|
||||
}
|
||||
|
||||
.hero-input-wrapper.error {
|
||||
box-shadow: 0 0 0 1px #F87171;
|
||||
}
|
||||
|
||||
.hero-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6AB0B3;
|
||||
|
|
@ -2333,6 +2566,7 @@
|
|||
font-weight: 600;
|
||||
letter-spacing: -0.006em;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-input:focus {
|
||||
|
|
@ -2343,8 +2577,27 @@
|
|||
color: #6AB0B3;
|
||||
}
|
||||
|
||||
.hero-input.error {
|
||||
box-shadow: none;
|
||||
.hero-input.has-value {
|
||||
color: #002224;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-input-clear {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-input-clear img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.hero-input-hint {
|
||||
|
|
@ -3346,6 +3599,31 @@
|
|||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.btn-kakao:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-kakao:disabled:hover {
|
||||
background-color: #FEE500;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Login Error */
|
||||
.login-error {
|
||||
color: #FF6B6B;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
max-width: 296px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Legacy styles - keep for backward compatibility */
|
||||
.login-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
|
|
@ -4308,6 +4586,30 @@
|
|||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.header-start-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 999px;
|
||||
background-color: #AE72F9;
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
letter-spacing: -0.006em;
|
||||
line-height: 1.19;
|
||||
}
|
||||
|
||||
.header-start-btn:hover {
|
||||
background-color: #9B5DE5;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(174, 114, 249, 0.3);
|
||||
}
|
||||
|
||||
.header-start-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="109" height="16" viewBox="0 0 109 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.47427 15.7435L8.37986 13.639H19.1989L13.9299 2.89788L2.97272 15.7482H0L12.677 1.00237C13.0962 0.517808 13.7203 0.0522496 14.4873 0.0522496C15.2543 0.0522496 15.7164 0.470302 15.9736 1.00237L23.3102 15.7482H6.47901L6.47427 15.7435ZM24.9109 15.7435L26.6069 6.09027H29.1365L27.8122 13.639H34.9391C38.8837 13.639 42.228 10.8124 42.228 7.15439C42.228 4.35154 40.1175 2.38481 36.9352 2.38481H27.2786L29.2747 0.256551H37.3068C41.8087 0.256551 44.7577 2.89786 44.7577 6.7411C44.7577 11.7672 40.1128 15.7482 34.5675 15.7482H24.9109V15.7435ZM55.4861 16C49.3787 16 46.2916 13.962 46.2916 9.5392C46.2916 3.1259 50.2172 0 58.3159 0C64.4234 0 67.5105 2.01425 67.5105 6.43705C67.5105 12.8504 63.6087 16 55.4861 16ZM57.9491 2.10927C51.2605 2.10927 48.8499 4.17102 48.8499 9.28741C48.8499 12.6461 51.0795 13.8955 55.8625 13.8955C62.5273 13.8955 64.9617 11.81 64.9617 6.6936C64.9617 3.33493 62.7322 2.10927 57.9491 2.10927ZM67.9344 15.7435L68.7491 11.1591C69.235 8.38004 71.3979 7.04038 74.9232 7.04038H81.7024C84.556 7.04038 85.7184 6.06651 85.7184 4.39906C85.7184 2.86936 84.1844 2.38481 81.2593 2.38481H71.2073L73.2272 0.256551H82.6742C86.6426 0.256551 88.2004 1.71497 88.2004 3.89074C88.2004 6.73634 86.3425 8.89313 81.6071 8.89313H74.8279C72.6937 8.89313 71.4836 9.56295 71.1835 11.1402L70.7642 13.639H86.7617L84.7656 15.7435H67.9344ZM89.0198 15.639C88.6197 15.639 88.3195 15.4062 88.3195 15.0309C88.3195 14.5131 88.7721 14.2185 89.2771 14.2185C89.6916 14.2185 90.0012 14.4371 90.0012 14.8124C90.0012 15.3302 89.5486 15.639 89.0151 15.639H89.0198ZM94.932 15.5249L95.9943 14.3468H102.035L99.0909 8.3468L92.9692 15.5249H91.3113L98.3906 7.28743C98.624 7.01664 98.9718 6.75536 99.4006 6.75536C99.8293 6.75536 100.087 6.98814 100.23 7.28743L104.327 15.5249H94.9272H94.932ZM106.056 15.5249L107.599 6.87413H109L107.456 15.5249H106.056Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1,31 @@
|
|||
<svg width="705" height="142" viewBox="0 0 705 142" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.02548 141.696L9.09356 139.385H20.8635L15.1458 127.675L3.22381 141.696H0L13.7772 125.607C14.2334 125.09 14.9025 124.573 15.7541 124.573C16.6057 124.573 17.0922 125.029 17.3659 125.607L25.3342 141.696H7.02548Z" fill="white"/>
|
||||
<path d="M47.7472 124.817H50.4844L48.7813 134.336C47.8689 139.445 44.189 142 37.6501 142C31.1112 142 28.0394 140.023 28.0394 136.039C28.0394 135.522 28.0393 134.914 28.1914 134.336L29.8945 124.817H32.6318L30.9287 134.336C30.8374 134.792 30.8071 135.248 30.8071 135.644C30.8071 138.472 32.9663 139.719 38.0757 139.719C43.9151 139.719 45.4053 138.137 46.044 134.336L47.7472 124.817Z" fill="white"/>
|
||||
<path d="M59.7616 141.696L62.3468 127.128H53.3748L55.6254 124.817H75.1507L72.9611 127.128H65.0839L62.4989 141.696H59.7616Z" fill="white"/>
|
||||
<path d="M84.2146 142C77.5845 142 74.2085 139.78 74.2085 134.944C74.2085 127.949 78.4663 124.543 87.2862 124.543C93.9163 124.543 97.2923 126.732 97.2923 131.568C97.2923 138.563 93.0649 142 84.2146 142ZM86.891 126.854C79.6222 126.854 77.0066 129.105 77.0066 134.67C77.0066 138.32 79.4395 139.688 84.6402 139.688C91.8786 139.688 94.5246 137.408 94.5246 131.842C94.5246 128.192 92.0917 126.854 86.891 126.854Z" fill="white"/>
|
||||
<path d="M124.846 141.695L122.565 128.101L114.627 140.844C114.079 141.756 113.471 141.908 112.832 141.908C112.194 141.908 111.646 141.756 111.433 140.844L108.088 128.101L100.88 141.695H98.2036L106.537 125.88C106.993 125.059 107.632 124.603 108.331 124.603C109.031 124.603 109.578 124.998 109.73 125.607L113.258 138.897L121.804 125.607C122.169 125.029 122.686 124.603 123.508 124.603C124.237 124.603 124.755 125.059 124.876 125.88L127.583 141.695H124.846Z" fill="white"/>
|
||||
<path d="M137.195 141.696L139.263 139.384H151.033L145.315 127.675L133.393 141.696H130.169L143.947 125.607C144.403 125.09 145.072 124.573 145.923 124.573C146.775 124.573 147.262 125.029 147.535 125.607L155.504 141.696H137.195Z" fill="white"/>
|
||||
<path d="M161.585 141.696L164.17 127.128H155.198L157.448 124.817H176.974L174.784 127.128H166.907L164.322 141.696H161.585Z" fill="white"/>
|
||||
<path d="M177.525 141.696L180.505 124.817H198.449L196.259 127.128H182.847L181.935 132.207H196.32L194.404 134.214H181.57L180.657 139.384H195.955L193.765 141.696H177.494H177.525Z" fill="white"/>
|
||||
<path d="M199.237 141.696L201.092 131.173H203.83L202.4 139.415H210.156C214.444 139.415 218.093 136.343 218.093 132.359C218.093 129.318 215.782 127.158 212.345 127.158H201.853L204.012 124.847H212.741C217.637 124.847 220.831 127.736 220.831 131.903C220.831 137.377 215.782 141.726 209.76 141.726H199.268L199.237 141.696Z" fill="white"/>
|
||||
<path d="M260.31 141.695L258.029 128.101L250.091 140.844C249.543 141.756 248.935 141.908 248.296 141.908C247.658 141.908 247.11 141.756 246.897 140.844L243.552 128.101L236.344 141.695H233.667L242.001 125.88C242.457 125.059 243.096 124.603 243.795 124.603C244.495 124.603 245.042 124.998 245.194 125.607L248.722 138.897L257.268 125.607C257.633 125.029 258.15 124.603 258.971 124.603C259.701 124.603 260.218 125.059 260.34 125.88L263.047 141.695H260.31Z" fill="white"/>
|
||||
<path d="M272.657 141.696L274.725 139.384H286.495L280.777 127.675L268.855 141.696H265.631L279.409 125.607C279.865 125.09 280.534 124.573 281.385 124.573C282.237 124.573 282.724 125.029 282.997 125.607L290.966 141.696H272.657Z" fill="white"/>
|
||||
<path d="M313.106 141.696L308.057 136.13H298.325L297.352 141.696H294.615L295.983 133.91H308.088C311.525 133.91 314.718 132.876 314.718 130.048C314.718 127.797 312.498 127.128 309.304 127.128H297.139L299.329 124.817H310.703C314.292 124.817 317.607 126.124 317.607 129.622C317.607 133.119 314.87 135.279 310.977 135.644L316.817 141.726H313.106V141.696Z" fill="white"/>
|
||||
<path d="M320.558 141.696L323.539 124.817H341.483L339.293 127.128H325.88L324.968 132.207H339.354L337.437 134.214H324.603L323.691 139.384H338.989L336.799 141.696H320.528H320.558Z" fill="white"/>
|
||||
<path d="M357.723 141.696L346.44 133.606L345.011 141.696H342.273L345.254 124.817H347.991L346.683 132.207L359.67 124.817H364.019L348.995 132.967L361.768 141.696H357.693H357.723Z" fill="white"/>
|
||||
<path d="M371.925 141.696L374.51 127.128H365.539L367.789 124.817H387.314L385.125 127.128H377.248L374.663 141.696H371.925Z" fill="white"/>
|
||||
<path d="M387.922 141.696L390.933 124.817H393.67L390.659 141.696H387.922Z" fill="white"/>
|
||||
<path d="M400.515 128.222L398.142 141.695H395.679L398.416 126.306C398.629 125.09 399.389 124.603 400.241 124.603C400.575 124.603 400.94 124.725 401.305 125.12L412.589 138.259L414.961 124.725H417.424L414.687 140.114C414.474 141.33 413.714 141.817 412.862 141.817C412.498 141.817 412.132 141.726 411.798 141.3L400.515 128.161V128.222Z" fill="white"/>
|
||||
<path d="M428.555 141.696C423.537 141.696 420.222 138.533 420.222 134.306C420.222 128.74 425.331 124.817 431.536 124.817H442.393L440.234 127.128H431.14C426.639 127.128 422.959 129.895 422.959 134.153C422.959 137.347 425.362 139.415 428.981 139.415H436.463L437.406 134.093H427.552L429.589 132.025H440.508L438.805 141.726H428.616L428.555 141.696Z" fill="white"/>
|
||||
<path d="M454.955 141.696L457.936 124.817H475.88L473.69 127.128H460.277L459.365 132.207H473.751L471.835 134.214H459L458.088 139.384H473.386L471.196 141.696H454.925H454.955Z" fill="white"/>
|
||||
<path d="M478.404 124.817H481.749L488.562 138.807L500.514 124.817H503.708L489.961 140.905C489.474 141.453 488.836 141.939 487.954 141.939C487.132 141.939 486.646 141.483 486.372 140.905L478.434 124.817H478.404Z" fill="white"/>
|
||||
<path d="M504.437 141.696L507.418 124.817H525.362L523.172 127.128H509.76L508.847 132.207H523.233L521.317 134.214H508.482L507.57 139.384H522.868L520.678 141.696H504.407H504.437Z" fill="white"/>
|
||||
<path d="M544.615 141.696L539.567 136.13H529.834L528.861 141.696H526.124L527.493 133.91H539.597C543.034 133.91 546.227 132.876 546.227 130.048C546.227 127.797 544.007 127.128 540.814 127.128H528.648L530.838 124.817H542.213C545.801 124.817 549.117 126.124 549.117 129.622C549.117 133.119 546.379 135.279 542.486 135.644L548.326 141.726H544.615V141.696Z" fill="white"/>
|
||||
<path d="M559.241 141.696L560.336 135.431L551.546 124.817H554.983L562.039 133.363L571.923 124.817H575.482L563.073 135.431L561.978 141.696H559.241Z" fill="white"/>
|
||||
<path d="M580.652 124.816L582.933 138.411L590.871 125.668C591.449 124.755 592.057 124.603 592.666 124.603C593.335 124.603 593.882 124.755 594.095 125.668L597.441 138.411L604.649 124.816H607.325L598.961 140.631C598.536 141.452 597.897 141.908 597.167 141.908C596.498 141.908 595.95 141.513 595.768 140.905L592.24 127.614L583.724 140.905C583.359 141.452 582.842 141.908 581.991 141.908C581.291 141.908 580.774 141.452 580.622 140.631L577.946 124.816H580.652Z" fill="white"/>
|
||||
<path d="M623.872 141.696L625.21 134.093H612.224L610.885 141.696H608.148L611.129 124.817H613.866L612.619 131.781H625.575L626.853 124.817H629.59L626.579 141.696H623.842H623.872Z" fill="white"/>
|
||||
<path d="M631.625 141.696L634.606 124.817H652.55L650.36 127.128H636.948L636.035 132.207H650.421L648.505 134.214H635.67L634.758 139.384H650.056L647.866 141.696H631.595H631.625Z" fill="white"/>
|
||||
<path d="M671.803 141.696L666.754 136.13H657.022L656.049 141.696H653.312L654.68 133.91H666.785C670.222 133.91 673.415 132.876 673.415 130.048C673.415 127.797 671.195 127.128 668.001 127.128H655.836L658.026 124.817H669.4C672.989 124.817 676.304 126.124 676.304 129.622C676.304 133.119 673.567 135.279 669.674 135.644L675.514 141.726H671.803V141.696Z" fill="white"/>
|
||||
<path d="M679.255 141.696L682.236 124.817H700.18L697.99 127.128H684.578L683.665 132.207H698.051L696.135 134.214H683.3L682.388 139.384H697.686L695.496 141.696H679.225H679.255Z" fill="white"/>
|
||||
<path d="M49.6344 100.79L61.7998 87.3168H130.869L97.2313 18.5523L27.2804 100.82H8.30249L89.2326 6.41717C91.9089 3.31501 95.8931 0.334502 100.79 0.334502C105.686 0.334502 108.636 3.01088 110.279 6.41717L157.115 100.82H49.6647L49.6344 100.79ZM167.334 100.79L178.161 38.9899H194.311L185.856 87.3168H231.355C256.537 87.3168 277.887 69.2208 277.887 45.8025C277.887 27.8586 264.414 15.2676 244.098 15.2676H182.45L195.193 1.64244H246.47C275.211 1.64244 294.037 18.5521 294.037 43.1565C294.037 75.3339 264.383 100.82 228.982 100.82H167.334V100.79ZM362.527 102.432C323.537 102.432 303.83 89.3849 303.83 61.0701C303.83 20.012 328.89 0 380.593 0C419.583 0 439.291 12.8952 439.291 41.21C439.291 82.2681 414.382 102.432 362.527 102.432ZM378.251 13.5035C335.551 13.5035 320.162 26.7029 320.162 59.4581C320.162 80.9603 334.395 88.959 364.93 88.959C407.479 88.959 423.02 75.6077 423.02 42.8525C423.02 21.3503 408.786 13.5035 378.251 13.5035ZM441.998 100.79L447.199 71.4409C450.301 53.6491 464.108 45.0726 486.614 45.0726H529.893C548.11 45.0726 555.531 38.8379 555.531 28.1628C555.531 18.3697 545.738 15.2676 527.064 15.2676H462.892L475.787 1.64244H536.097C561.431 1.64244 571.377 10.9792 571.377 24.9085C571.377 43.1261 559.515 56.9339 529.284 56.9339H486.006C472.381 56.9339 464.656 61.2221 462.74 71.3193L460.063 87.3168H562.192L549.448 100.79H441.998ZM576.608 100.121C574.053 100.121 572.137 98.6305 572.137 96.2279C572.137 92.9128 575.026 91.0271 578.25 91.0271C580.896 91.0271 582.873 92.4261 582.873 94.8288C582.873 98.1439 579.983 100.121 576.577 100.121H576.608ZM614.351 99.3908L621.133 91.8484H659.697L640.902 53.4363L601.82 99.3908H591.236L636.431 46.6542C637.921 44.9206 640.141 43.2479 642.879 43.2479C645.616 43.2479 647.258 44.7381 648.17 46.6542L674.326 99.3908H614.32H614.351ZM685.366 99.3908L695.22 44.0082H704.161L694.308 99.3908H685.366Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="12" fill="black" fill-opacity="0.6"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.64671 7.64669C7.74047 7.55305 7.86755 7.50046 8.00005 7.50046C8.13255 7.50046 8.25963 7.55305 8.35338 7.64669L12 11.2934L15.6467 7.64669C15.6925 7.59756 15.7477 7.55816 15.809 7.53083C15.8704 7.50351 15.9366 7.48881 16.0037 7.48763C16.0708 7.48644 16.1375 7.49879 16.1998 7.52394C16.262 7.54909 16.3186 7.58652 16.3661 7.634C16.4136 7.68147 16.451 7.73803 16.4761 7.80029C16.5013 7.86255 16.5136 7.92923 16.5124 7.99637C16.5113 8.0635 16.4966 8.12971 16.4692 8.19105C16.4419 8.25238 16.4025 8.30758 16.3534 8.35335L12.7067 12L16.3534 15.6467C16.4025 15.6925 16.4419 15.7477 16.4692 15.809C16.4966 15.8703 16.5113 15.9365 16.5124 16.0037C16.5136 16.0708 16.5013 16.1375 16.4761 16.1998C16.451 16.262 16.4136 16.3186 16.3661 16.366C16.3186 16.4135 16.262 16.451 16.1998 16.4761C16.1375 16.5012 16.0708 16.5136 16.0037 16.5124C15.9366 16.5112 15.8704 16.4965 15.809 16.4692C15.7477 16.4419 15.6925 16.4025 15.6467 16.3534L12 12.7067L8.35338 16.3534C8.2586 16.4417 8.13323 16.4898 8.0037 16.4875C7.87417 16.4852 7.75058 16.4327 7.65897 16.3411C7.56736 16.2495 7.51488 16.1259 7.5126 15.9964C7.51031 15.8668 7.55839 15.7415 7.64671 15.6467L11.2934 12L7.64671 8.35335C7.55308 8.2596 7.50049 8.13252 7.50049 8.00002C7.50049 7.86752 7.55308 7.74044 7.64671 7.64669Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
196
src/App.tsx
196
src/App.tsx
|
|
@ -8,7 +8,7 @@ import LoadingSection from './pages/Analysis/LoadingSection';
|
|||
import AnalysisResultSection from './pages/Analysis/AnalysisResultSection';
|
||||
import LoginSection from './pages/Login/LoginSection';
|
||||
import GenerationFlow from './pages/Dashboard/GenerationFlow';
|
||||
import { crawlUrl } from './utils/api';
|
||||
import { crawlUrl, kakaoCallback, isLoggedIn, saveTokens } from './utils/api';
|
||||
import { CrawlingResponse } from './types/api';
|
||||
|
||||
type ViewMode = 'landing' | 'loading' | 'analysis' | 'login' | 'generation_flow';
|
||||
|
|
@ -46,15 +46,116 @@ const App: React.FC = () => {
|
|||
const savedViewMode = localStorage.getItem(VIEW_MODE_KEY) as ViewMode | null;
|
||||
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
savedViewMode === 'generation_flow' ? 'generation_flow' : 'landing'
|
||||
);
|
||||
// 저장된 분석 데이터 파싱 및 유효성 검사
|
||||
const parseSavedAnalysisData = (): CrawlingResponse | null => {
|
||||
if (!savedAnalysisData) return null;
|
||||
try {
|
||||
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
||||
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
||||
data.marketing_analysis.report = data.marketing_analysis.report || '';
|
||||
}
|
||||
if (data.processed_info) {
|
||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||
data.processed_info.region = data.processed_info.region || '';
|
||||
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
|
||||
}
|
||||
data.image_list = data.image_list || [];
|
||||
return data;
|
||||
} catch {
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 viewMode 결정: 로그인 상태면 바로 generation_flow로
|
||||
const getInitialViewMode = (): ViewMode => {
|
||||
if (savedViewMode === 'generation_flow') return 'generation_flow';
|
||||
if (isLoggedIn()) return 'generation_flow';
|
||||
return 'landing';
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(getInitialViewMode());
|
||||
const [initialTab, setInitialTab] = useState('새 프로젝트 만들기');
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(
|
||||
savedAnalysisData ? JSON.parse(savedAnalysisData) : null
|
||||
parseSavedAnalysisData()
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
|
||||
|
||||
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const accessToken = urlParams.get('access_token');
|
||||
const refreshToken = urlParams.get('refresh_token');
|
||||
const code = urlParams.get('code');
|
||||
|
||||
// 백엔드에서 토큰을 URL 파라미터로 전달한 경우
|
||||
if (accessToken && refreshToken && !isProcessingCallback) {
|
||||
setIsProcessingCallback(true);
|
||||
handleTokenCallback(accessToken, refreshToken);
|
||||
}
|
||||
// 기존 code 방식 (Redirect URI가 프론트엔드인 경우)
|
||||
else if (code && !isProcessingCallback) {
|
||||
setIsProcessingCallback(true);
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 백엔드에서 토큰을 URL 파라미터로 전달받은 경우 처리
|
||||
const handleTokenCallback = (accessToken: string, refreshToken: string) => {
|
||||
try {
|
||||
// 토큰 저장
|
||||
saveTokens(accessToken, refreshToken);
|
||||
|
||||
// URL에서 토큰 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('access_token');
|
||||
url.searchParams.delete('refresh_token');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
|
||||
// 로그인 성공 - 분석 데이터 유무에 따라 분기
|
||||
// 비로그인 상태에서 URL 입력 후 브랜드 분석을 본 경우 → 바로 generation_flow로 (URL 입력 스킵)
|
||||
// 홈에서 바로 로그인한 경우 → generation_flow로 (URL 입력 필요)
|
||||
const savedData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
if (savedData) {
|
||||
// 분석 데이터가 있으면 바로 에셋 관리로
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
} else {
|
||||
// 분석 데이터가 없으면 URL 입력부터 시작하도록 generation_flow로
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Token callback failed:', err);
|
||||
alert('로그인 처리에 실패했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
setIsProcessingCallback(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKakaoCallback = async (code: string) => {
|
||||
try {
|
||||
const response = await kakaoCallback(code);
|
||||
|
||||
// 로그인 성공 - 서버에서 받은 redirect_url로 이동
|
||||
window.location.href = response.redirect_url;
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
alert('카카오 로그인에 실패했습니다. 다시 시도해주세요.');
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
} finally {
|
||||
setIsProcessingCallback(false);
|
||||
}
|
||||
};
|
||||
|
||||
// viewMode 변경 시 localStorage에 저장
|
||||
useEffect(() => {
|
||||
|
|
@ -88,6 +189,43 @@ const App: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// 크롤링 응답 유효성 검사
|
||||
const validateCrawlingResponse = (data: CrawlingResponse): boolean => {
|
||||
// 필수 필드 존재 여부 확인
|
||||
if (!data) return false;
|
||||
if (!data.processed_info) return false;
|
||||
if (!data.marketing_analysis) return false;
|
||||
|
||||
// marketing_analysis 내부 필드 기본값 보장
|
||||
if (!data.marketing_analysis.tags) {
|
||||
data.marketing_analysis.tags = [];
|
||||
}
|
||||
if (!data.marketing_analysis.facilities) {
|
||||
data.marketing_analysis.facilities = [];
|
||||
}
|
||||
if (!data.marketing_analysis.report) {
|
||||
data.marketing_analysis.report = '';
|
||||
}
|
||||
|
||||
// processed_info 내부 필드 기본값 보장
|
||||
if (!data.processed_info.customer_name) {
|
||||
data.processed_info.customer_name = '알 수 없음';
|
||||
}
|
||||
if (!data.processed_info.region) {
|
||||
data.processed_info.region = '';
|
||||
}
|
||||
if (!data.processed_info.detail_region_info) {
|
||||
data.processed_info.detail_region_info = '';
|
||||
}
|
||||
|
||||
// image_list 기본값 보장
|
||||
if (!data.image_list) {
|
||||
data.image_list = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleStartAnalysis = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
|
|
@ -96,18 +234,40 @@ const App: React.FC = () => {
|
|||
|
||||
try {
|
||||
const data = await crawlUrl(url);
|
||||
|
||||
// 응답 유효성 검사
|
||||
if (!validateCrawlingResponse(data)) {
|
||||
throw new Error('유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.');
|
||||
}
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
setViewMode('analysis');
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
setError('분석 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
setError(errorMessage);
|
||||
setViewMode('landing');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToLogin = () => {
|
||||
setViewMode('login');
|
||||
const handleToLogin = async () => {
|
||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||
if (isLoggedIn()) {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 안 된 상태면 카카오 로그인 페이지로 리다이렉션
|
||||
try {
|
||||
const { getKakaoLoginUrl } = await import('./utils/api');
|
||||
const response = await getKakaoLoginUrl();
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
|
|
@ -124,6 +284,17 @@ const App: React.FC = () => {
|
|||
setViewMode('landing');
|
||||
};
|
||||
|
||||
// 카카오 콜백 처리 중 로딩 화면 표시
|
||||
if (isProcessingCallback) {
|
||||
return (
|
||||
<div className="login-container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: '#fff' }}>
|
||||
<p style={{ fontSize: '18px' }}>로그인 처리 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'loading') {
|
||||
return <LoadingSection />;
|
||||
}
|
||||
|
|
@ -149,13 +320,20 @@ const App: React.FC = () => {
|
|||
initialActiveItem={initialTab}
|
||||
initialImageList={analysisData?.image_list || []}
|
||||
businessInfo={analysisData?.processed_info}
|
||||
initialAnalysisData={analysisData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인된 상태에서 "시작하기" 버튼 클릭
|
||||
const handleHeaderStart = () => {
|
||||
setInitialTab('새 프로젝트 만들기');
|
||||
setViewMode('generation_flow');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="landing-container" ref={containerRef}>
|
||||
<Header />
|
||||
<Header onStartClick={handleHeaderStart} />
|
||||
<section className="landing-section">
|
||||
<HeroSection
|
||||
onAnalyze={handleStartAnalysis}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,57 @@
|
|||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { getKakaoLoginUrl, isLoggedIn } from '../utils/api';
|
||||
|
||||
interface HeaderProps {
|
||||
onStartClick?: () => void;
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({ onStartClick }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
alert('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (onStartClick) {
|
||||
onStartClick();
|
||||
}
|
||||
};
|
||||
|
||||
const Header: React.FC = () => {
|
||||
return (
|
||||
<header className="landing-header">
|
||||
<div className="header-logo">
|
||||
<img src="/assets/images/logo.svg" alt="CASTAD" />
|
||||
<img src="/assets/images/ado2-header-logo.svg" alt="ADO2" />
|
||||
</div>
|
||||
<button className="header-login-btn">
|
||||
로그인
|
||||
{loggedIn ? (
|
||||
<button
|
||||
className="header-start-btn"
|
||||
onClick={handleStart}
|
||||
>
|
||||
시작하기
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="header-login-btn"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '로딩...' : '로그인'}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome }) =>
|
|||
const menuItems = [
|
||||
{ id: '대시보드', label: '대시보드', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> },
|
||||
{ id: '새 프로젝트 만들기', label: '새 프로젝트 만들기', disabled: false, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> },
|
||||
{ id: '내 보관함', label: '내 보관함', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: 'ADO2 콘텐츠', label: 'ADO2 콘텐츠', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> },
|
||||
{ id: '에셋 관리', label: '에셋 관리', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> },
|
||||
{ id: '내 펜션', label: '내 펜션', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> },
|
||||
{ id: '계정 설정', label: '계정 설정', disabled: true, icon: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><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> },
|
||||
|
|
|
|||
|
|
@ -50,52 +50,48 @@ const formatReportText = (text: string): React.ReactNode[] => {
|
|||
});
|
||||
};
|
||||
|
||||
// 마크다운 report를 섹션별로 파싱
|
||||
const parseReport = (report: string) => {
|
||||
if (!report || report.trim() === '') {
|
||||
return [];
|
||||
}
|
||||
// 셀링 포인트 카드 타입
|
||||
interface SellingPointCard {
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const sections: { title: string; content: string }[] = [];
|
||||
const lines = report.split('\n');
|
||||
let currentTitle = '';
|
||||
let currentContent: string[] = [];
|
||||
let hasMarkdownHeaders = report.includes('## ');
|
||||
// facilities 배열을 셀링 포인트 카드 형태로 변환
|
||||
const parseSellingPoints = (facilities: string[]): SellingPointCard[] => {
|
||||
// 기본 카테고리 정의
|
||||
const categories: SellingPointCard[] = [
|
||||
{ title: '브랜드 컨셉', items: [] },
|
||||
{ title: '프라이버시', items: [] },
|
||||
{ title: '로컬 결합', items: [] },
|
||||
{ title: '무드/비주얼', items: [] },
|
||||
{ title: '편의/신뢰', items: [] },
|
||||
{ title: '체류형 가치', items: [] },
|
||||
];
|
||||
|
||||
// 마크다운 헤더가 없는 경우 전체 텍스트를 하나의 섹션으로 반환
|
||||
if (!hasMarkdownHeaders) {
|
||||
return [{ title: '분석 결과', content: report.trim() }];
|
||||
}
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith('## ')) {
|
||||
if (currentTitle || currentContent.length > 0) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
currentTitle = line.replace('## ', '').trim();
|
||||
currentContent = [];
|
||||
} else if (!line.startsWith('# ')) {
|
||||
currentContent.push(line);
|
||||
// facilities를 카테고리에 분배 (최대 2개씩)
|
||||
facilities.forEach((facility, idx) => {
|
||||
const categoryIdx = Math.floor(idx / 2) % categories.length;
|
||||
if (categories[categoryIdx].items.length < 2) {
|
||||
categories[categoryIdx].items.push(facility);
|
||||
}
|
||||
});
|
||||
|
||||
if (currentTitle || currentContent.length > 0) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
|
||||
return sections.filter(s => s.content && !s.title.includes('JSON'));
|
||||
// 아이템이 있는 카테고리만 반환
|
||||
return categories.filter(cat => cat.items.length > 0);
|
||||
};
|
||||
|
||||
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
||||
const { processed_info, marketing_analysis } = data;
|
||||
const tags = marketing_analysis.tags || [];
|
||||
const facilities = marketing_analysis.facilities || [];
|
||||
const reportSections = parseReport(marketing_analysis.report);
|
||||
const sellingPoints = parseSellingPoints(facilities);
|
||||
|
||||
return (
|
||||
<div className="analysis-container">
|
||||
{/* Header Area */}
|
||||
<div className="analysis-header-area">
|
||||
{/* Back Button */}
|
||||
<div className="back-button-container" style={{ marginLeft: 0 }}>
|
||||
<div className="back-button-container">
|
||||
<button onClick={onBack} className="btn-back">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
|
|
@ -116,63 +112,52 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="analysis-grid">
|
||||
{/* Brand Identity */}
|
||||
{/* Left: Brand Identity (Scrollable) */}
|
||||
<div className="brand-identity-card">
|
||||
<div className="brand-header">
|
||||
<span className="section-title">브랜드 정체성</span>
|
||||
<span className="brand-subtitle">AI 마케팅 분석 요약</span>
|
||||
</div>
|
||||
|
||||
<div className="brand-content">
|
||||
<div className="brand-info">
|
||||
<h2 className="brand-name">{processed_info.customer_name}</h2>
|
||||
<p className="brand-location">{processed_info.region} · {processed_info.detail_region_info}</p>
|
||||
<p className="brand-location">{processed_info.detail_region_info}</p>
|
||||
</div>
|
||||
|
||||
{/* Marketing Analysis Summary */}
|
||||
<div className="report-section">
|
||||
<div className="report-content custom-scrollbar">
|
||||
{reportSections.length === 0 ? (
|
||||
<div>
|
||||
<div className="report-content">
|
||||
{marketing_analysis.report
|
||||
? formatReportText(marketing_analysis.report)
|
||||
: '분석 결과가 없습니다.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="report-sections">
|
||||
{reportSections.map((section, idx) => (
|
||||
<div key={idx}>
|
||||
{section.title && <h4 className="report-section-title">{section.title}</h4>}
|
||||
<div>
|
||||
{formatReportText(section.content)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Cards */}
|
||||
{/* Right: Selling Points & Keywords (Fixed) */}
|
||||
<div className="analysis-cards-column">
|
||||
{/* Main Selling Points (Facilities) */}
|
||||
<div className="feature-card">
|
||||
<span className="section-title-purple">주요 셀링 포인트</span>
|
||||
<div className="tags-wrapper">
|
||||
{facilities.map((facility, idx) => (
|
||||
<span key={idx} className="feature-tag">
|
||||
{facility}
|
||||
</span>
|
||||
{/* Main Selling Points */}
|
||||
<div className="feature-card selling-points-card">
|
||||
<span className="section-title">주요 셀링 포인트</span>
|
||||
<div className="selling-points-grid">
|
||||
{sellingPoints.map((point, idx) => (
|
||||
<div key={idx} className="selling-point-item">
|
||||
<span className="selling-point-title">{point.title}</span>
|
||||
<div className="selling-point-content">
|
||||
{point.items.map((item, itemIdx) => (
|
||||
<p key={itemIdx}>{item}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Target Keywords (Tags) */}
|
||||
<div className="feature-card">
|
||||
{/* Recommended Target Keywords */}
|
||||
<div className="feature-card keywords-card">
|
||||
<span className="section-title">추천 타겟 키워드</span>
|
||||
<div className="tags-wrapper">
|
||||
{tags.map((tag, idx) => (
|
||||
|
|
|
|||
|
|
@ -6,12 +6,17 @@ import SoundStudioContent from './SoundStudioContent';
|
|||
import CompletionContent from './CompletionContent';
|
||||
import DashboardContent from './DashboardContent';
|
||||
import BusinessSettingsContent from './BusinessSettingsContent';
|
||||
import { ImageItem } from '../../types/api';
|
||||
import UrlInputContent from './UrlInputContent';
|
||||
import LoadingSection from '../Analysis/LoadingSection';
|
||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||
import { ImageItem, CrawlingResponse } from '../../types/api';
|
||||
import { crawlUrl } from '../../utils/api';
|
||||
|
||||
const WIZARD_STEP_KEY = 'castad_wizard_step';
|
||||
const ACTIVE_ITEM_KEY = 'castad_active_item';
|
||||
const SONG_TASK_ID_KEY = 'castad_song_task_id';
|
||||
const IMAGE_TASK_ID_KEY = 'castad_image_task_id';
|
||||
const ANALYSIS_DATA_KEY = 'castad_analysis_data';
|
||||
|
||||
// 다른 컴포넌트에서 사용하는 storage key들 (초기화용)
|
||||
const SONG_GENERATION_KEY = 'castad_song_generation';
|
||||
|
|
@ -37,27 +42,82 @@ interface GenerationFlowProps {
|
|||
initialActiveItem?: string;
|
||||
initialImageList?: string[];
|
||||
businessInfo?: BusinessInfo;
|
||||
initialAnalysisData?: CrawlingResponse | null;
|
||||
}
|
||||
|
||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveItem = '대시보드', initialImageList = [], businessInfo }) => {
|
||||
// 위저드 단계:
|
||||
// -2: URL 입력
|
||||
// -1: 로딩 (분석 중)
|
||||
// 0: 브랜드 분석 결과
|
||||
// 1: 에셋 관리 (Asset Management)
|
||||
// 2: 사운드 스튜디오 (Sound Studio)
|
||||
// 3: 완료 (Completion)
|
||||
|
||||
const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||
onHome,
|
||||
initialActiveItem = '대시보드',
|
||||
initialImageList = [],
|
||||
businessInfo,
|
||||
initialAnalysisData
|
||||
}) => {
|
||||
// localStorage에서 저장된 상태 복원
|
||||
const savedActiveItem = localStorage.getItem(ACTIVE_ITEM_KEY);
|
||||
const savedWizardStep = localStorage.getItem(WIZARD_STEP_KEY);
|
||||
const savedSongTaskId = localStorage.getItem(SONG_TASK_ID_KEY);
|
||||
const savedImageTaskId = localStorage.getItem(IMAGE_TASK_ID_KEY);
|
||||
const savedAnalysisData = localStorage.getItem(ANALYSIS_DATA_KEY);
|
||||
|
||||
// 분석 데이터 파싱
|
||||
const parseAnalysisData = (): CrawlingResponse | null => {
|
||||
if (initialAnalysisData) return initialAnalysisData;
|
||||
if (!savedAnalysisData) return null;
|
||||
try {
|
||||
return JSON.parse(savedAnalysisData) as CrawlingResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [activeItem, setActiveItem] = useState(savedActiveItem || initialActiveItem);
|
||||
// 현재 위저드 단계 (0: Asset, 1: Sound Studio, 2: Completion)
|
||||
const [wizardStep, setWizardStep] = useState(savedWizardStep ? parseInt(savedWizardStep, 10) : 0);
|
||||
|
||||
// 초기 위저드 단계 결정
|
||||
const getInitialWizardStep = (): number => {
|
||||
// 저장된 단계가 있으면 사용
|
||||
if (savedWizardStep !== null) {
|
||||
return parseInt(savedWizardStep, 10);
|
||||
}
|
||||
// 분석 데이터가 있으면 에셋 관리(1)부터, 없으면 URL 입력(-2)부터
|
||||
const hasAnalysisData = initialAnalysisData || savedAnalysisData;
|
||||
return hasAnalysisData ? 1 : -2;
|
||||
};
|
||||
|
||||
const [wizardStep, setWizardStep] = useState(getInitialWizardStep());
|
||||
const [songTaskId, setSongTaskId] = useState<string | null>(savedSongTaskId);
|
||||
const [imageTaskId, setImageTaskId] = useState<string | null>(savedImageTaskId);
|
||||
const [videoGenerationStatus, setVideoGenerationStatus] = useState<'idle' | 'generating' | 'complete' | 'error'>('idle');
|
||||
const [videoGenerationProgress, setVideoGenerationProgress] = useState(0);
|
||||
const [analysisData, setAnalysisData] = useState<CrawlingResponse | null>(parseAnalysisData());
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
|
||||
// 현재 비즈니스 정보 (분석 데이터에서 가져오거나 prop에서 가져옴)
|
||||
const currentBusinessInfo = analysisData?.processed_info || businessInfo;
|
||||
|
||||
// URL 이미지를 ImageItem 형태로 변환하여 초기화
|
||||
const [imageList, setImageList] = useState<ImageItem[]>(
|
||||
initialImageList.map(url => ({ type: 'url', url }))
|
||||
);
|
||||
const getInitialImageList = (): ImageItem[] => {
|
||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||
return analysisData.image_list.map(url => ({ type: 'url', url }));
|
||||
}
|
||||
return initialImageList.map(url => ({ type: 'url', url }));
|
||||
};
|
||||
|
||||
const [imageList, setImageList] = useState<ImageItem[]>(getInitialImageList());
|
||||
|
||||
// analysisData 변경 시 imageList 업데이트
|
||||
useEffect(() => {
|
||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
||||
}
|
||||
}, [analysisData]);
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImageList(prev => {
|
||||
|
|
@ -83,10 +143,12 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
// 홈 버튼(로고) 클릭 시 모든 상태 초기화 후 홈으로 이동
|
||||
const handleHome = () => {
|
||||
clearAllProjectStorage();
|
||||
setWizardStep(0);
|
||||
localStorage.removeItem(ANALYSIS_DATA_KEY);
|
||||
setWizardStep(-2);
|
||||
setSongTaskId(null);
|
||||
setImageTaskId(null);
|
||||
setImageList(initialImageList.map(url => ({ type: 'url', url })));
|
||||
setAnalysisData(null);
|
||||
setImageList([]);
|
||||
onHome();
|
||||
};
|
||||
|
||||
|
|
@ -96,6 +158,50 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
localStorage.setItem(WIZARD_STEP_KEY, step.toString());
|
||||
};
|
||||
|
||||
// URL 분석 시작
|
||||
const handleStartAnalysis = async (url: string) => {
|
||||
if (!url.trim()) return;
|
||||
|
||||
goToWizardStep(-1); // 로딩 상태로
|
||||
setAnalysisError(null);
|
||||
|
||||
try {
|
||||
const data = await crawlUrl(url);
|
||||
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
data.marketing_analysis.tags = data.marketing_analysis.tags || [];
|
||||
data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
|
||||
data.marketing_analysis.report = data.marketing_analysis.report || '';
|
||||
}
|
||||
if (data.processed_info) {
|
||||
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
|
||||
data.processed_info.region = data.processed_info.region || '';
|
||||
data.processed_info.detail_region_info = data.processed_info.detail_region_info || '';
|
||||
}
|
||||
data.image_list = data.image_list || [];
|
||||
|
||||
setAnalysisData(data);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(data));
|
||||
goToWizardStep(0); // 브랜드 분석 결과로
|
||||
} catch (err) {
|
||||
console.error('Crawling failed:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : '분석 중 오류가 발생했습니다. 다시 시도해주세요.';
|
||||
setAnalysisError(errorMessage);
|
||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||
}
|
||||
};
|
||||
|
||||
// 브랜드 분석에서 콘텐츠 생성 클릭
|
||||
const handleAnalysisGenerate = () => {
|
||||
goToWizardStep(1); // 에셋 관리로
|
||||
};
|
||||
|
||||
// 브랜드 분석에서 뒤로가기
|
||||
const handleAnalysisBack = () => {
|
||||
goToWizardStep(-2); // URL 입력으로
|
||||
};
|
||||
|
||||
// activeItem 변경 시 localStorage에 저장
|
||||
useEffect(() => {
|
||||
localStorage.setItem(ACTIVE_ITEM_KEY, activeItem);
|
||||
|
|
@ -104,7 +210,31 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||
const renderWizardContent = () => {
|
||||
switch (wizardStep) {
|
||||
case -2:
|
||||
// URL 입력 단계
|
||||
return (
|
||||
<UrlInputContent
|
||||
onAnalyze={handleStartAnalysis}
|
||||
error={analysisError}
|
||||
/>
|
||||
);
|
||||
case -1:
|
||||
// 로딩 단계
|
||||
return <LoadingSection />;
|
||||
case 0:
|
||||
// 브랜드 분석 결과 단계
|
||||
if (!analysisData) {
|
||||
goToWizardStep(-2);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AnalysisResultSection
|
||||
onBack={handleAnalysisBack}
|
||||
onGenerate={handleAnalysisGenerate}
|
||||
data={analysisData}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<AssetManagementContent
|
||||
onNext={(taskId: string) => {
|
||||
|
|
@ -115,32 +245,32 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
|
||||
setImageTaskId(taskId);
|
||||
localStorage.setItem(IMAGE_TASK_ID_KEY, taskId);
|
||||
goToWizardStep(1);
|
||||
goToWizardStep(2);
|
||||
}}
|
||||
imageList={imageList}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onAddImages={handleAddImages}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
case 2:
|
||||
return (
|
||||
<SoundStudioContent
|
||||
onBack={() => goToWizardStep(0)}
|
||||
onBack={() => goToWizardStep(1)}
|
||||
onNext={(taskId: string) => {
|
||||
setSongTaskId(taskId);
|
||||
localStorage.setItem(SONG_TASK_ID_KEY, taskId);
|
||||
goToWizardStep(2);
|
||||
goToWizardStep(3);
|
||||
}}
|
||||
businessInfo={businessInfo}
|
||||
businessInfo={currentBusinessInfo}
|
||||
imageTaskId={imageTaskId}
|
||||
videoGenerationStatus={videoGenerationStatus}
|
||||
videoGenerationProgress={videoGenerationProgress}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
case 3:
|
||||
return (
|
||||
<CompletionContent
|
||||
onBack={() => goToWizardStep(1)}
|
||||
onBack={() => goToWizardStep(2)}
|
||||
songTaskId={songTaskId}
|
||||
onVideoStatusChange={setVideoGenerationStatus}
|
||||
onVideoProgressChange={setVideoGenerationProgress}
|
||||
|
|
@ -158,6 +288,10 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
case '비즈니스 설정':
|
||||
return <BusinessSettingsContent />;
|
||||
case '새 프로젝트 만들기':
|
||||
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
||||
if (wizardStep === 0 || wizardStep === -1) {
|
||||
return renderWizardContent();
|
||||
}
|
||||
return (
|
||||
<div className="wizard-page-container">
|
||||
{renderWizardContent()}
|
||||
|
|
@ -172,10 +306,34 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({ onHome, initialActiveIt
|
|||
}
|
||||
};
|
||||
|
||||
// 로딩 화면에서만 Sidebar 숨김
|
||||
const showSidebar = wizardStep !== -1;
|
||||
|
||||
// 브랜드 분석(0)일 때는 전체 페이지 스크롤
|
||||
const isBrandAnalysis = activeItem === '새 프로젝트 만들기' && wizardStep === 0;
|
||||
// 스크롤이 필요한 페이지: 대시보드, 비즈니스 설정, 브랜드 분석(0)
|
||||
const needsScroll = activeItem === '대시보드' || activeItem === '비즈니스 설정' || isBrandAnalysis;
|
||||
|
||||
// 브랜드 분석일 때는 높이 제한 없이 자연 스크롤
|
||||
if (isBrandAnalysis) {
|
||||
return (
|
||||
<div className={`flex w-full bg-[#0d1416] text-white ${activeItem === '대시보드' || activeItem === '비즈니스 설정' ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||
<div className="flex w-full bg-[#0d1416] text-white">
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 ${activeItem === '대시보드' || activeItem === '비즈니스 설정' ? 'overflow-y-auto' : 'h-full overflow-hidden'}`}>
|
||||
)}
|
||||
<div className="flex-1 relative">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex w-full bg-[#0d1416] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||
{showSidebar && (
|
||||
<Sidebar activeItem={activeItem} onNavigate={setActiveItem} onHome={handleHome} />
|
||||
)}
|
||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-y-auto' : 'h-full overflow-hidden'}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface UrlInputContentProps {
|
||||
onAnalyze: (url: string) => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, error }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (url.trim()) {
|
||||
onAnalyze(url.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="url-input-container">
|
||||
<div className="url-input-content">
|
||||
{/* 아이콘 */}
|
||||
<div className="url-input-icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.4L12 22l-2.4-7.2L2 12l7.6-2.4z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h1 className="url-input-title">브랜드 분석</h1>
|
||||
<p className="url-input-subtitle">
|
||||
쉽고 빠르게, 브랜드 소셜 미디어 캠페인을 만드세요.
|
||||
</p>
|
||||
|
||||
{/* URL 입력 폼 */}
|
||||
<form onSubmit={handleSubmit} className="url-input-form">
|
||||
<div className="url-input-wrapper">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="네이버 지도 URL을 입력하세요"
|
||||
className="url-input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url.trim()}
|
||||
className="url-input-button"
|
||||
>
|
||||
분석 시작
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<p className="url-input-error">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 안내 텍스트 */}
|
||||
<p className="url-input-guide">
|
||||
네이버 지도에서 업체 URL을 복사하여 붙여넣기 해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlInputContent;
|
||||
|
|
@ -50,6 +50,7 @@ const orbConfigs: OrbConfig[] = [
|
|||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: externalError, scrollProgress = 0 }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const animationRefs = useRef<number[]>([]);
|
||||
|
||||
|
|
@ -174,8 +175,8 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
|
|||
<div className="hero-content">
|
||||
{/* Logo Image */}
|
||||
<img
|
||||
src="/assets/images/castad-logo-large.svg"
|
||||
alt="CASTAD"
|
||||
src="/assets/images/ado2-logo.svg"
|
||||
alt="ADO2"
|
||||
className="hero-logo"
|
||||
/>
|
||||
|
||||
|
|
@ -183,7 +184,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
|
|||
{/* Input Form */}
|
||||
<div className="hero-form">
|
||||
<span className="hero-input-label">URL 입력</span>
|
||||
<div className="hero-input-wrapper">
|
||||
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${url && !isFocused ? 'filled' : ''}`}>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
|
|
@ -191,9 +192,23 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onNext, error: ext
|
|||
setUrl(e.target.value);
|
||||
if (localError) setLocalError('');
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="https://www.castad.com"
|
||||
className={`hero-input ${error ? 'error' : ''}`}
|
||||
className={`hero-input ${url ? 'has-value' : ''}`}
|
||||
/>
|
||||
{url && (
|
||||
<button
|
||||
type="button"
|
||||
className="hero-input-clear"
|
||||
onClick={() => {
|
||||
setUrl('');
|
||||
setLocalError('');
|
||||
}}
|
||||
>
|
||||
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="hero-input-hint">URL에서 가져온 정보로 영상이 자동 생성됩니다.</span>
|
||||
{error && (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getKakaoLoginUrl, kakaoCallback } from '../../utils/api';
|
||||
|
||||
interface LoginSectionProps {
|
||||
onBack: () => void;
|
||||
|
|
@ -7,10 +8,66 @@ interface LoginSectionProps {
|
|||
}
|
||||
|
||||
const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 카카오 콜백 처리 (URL에서 code 파라미터 확인)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (code) {
|
||||
handleKakaoCallback(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKakaoCallback = async (code: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await kakaoCallback(code);
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
|
||||
// 로그인 성공
|
||||
onLogin();
|
||||
} catch (err) {
|
||||
console.error('Kakao callback failed:', err);
|
||||
setError('카카오 로그인에 실패했습니다. 다시 시도해주세요.');
|
||||
|
||||
// URL에서 code 파라미터 제거
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('code');
|
||||
window.history.replaceState({}, document.title, url.pathname);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKakaoLogin = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await getKakaoLoginUrl();
|
||||
|
||||
// 카카오 로그인 페이지로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (err) {
|
||||
console.error('Failed to get Kakao login URL:', err);
|
||||
setError('로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
{/* Back Button */}
|
||||
<button onClick={onBack} className="login-back-btn">
|
||||
<button onClick={onBack} className="login-back-btn" disabled={isLoading}>
|
||||
<img src="/assets/images/icon-back.svg" alt="Back" />
|
||||
<span>뒤로가기</span>
|
||||
</button>
|
||||
|
|
@ -21,9 +78,20 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
|
|||
<img src="/assets/images/login-logo.svg" alt="CASTAD" />
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="login-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kakao Login Button */}
|
||||
<button onClick={onLogin} className="btn-kakao">
|
||||
카카오로 시작하기
|
||||
<button
|
||||
onClick={handleKakaoLogin}
|
||||
className="btn-kakao"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '로그인 중...' : '카카오로 시작하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -180,3 +180,43 @@ export const LANGUAGE_MAP: Record<string, string> = {
|
|||
'ไทย': 'Thai',
|
||||
'Tiếng Việt': 'Vietnamese',
|
||||
};
|
||||
|
||||
// 카카오 로그인 URL 응답
|
||||
export interface KakaoLoginUrlResponse {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
// 카카오 콜백 사용자 정보
|
||||
export interface KakaoCallbackUser {
|
||||
id: number;
|
||||
nickname: string;
|
||||
email: string | null;
|
||||
profile_image_url: string | null;
|
||||
is_new_user: boolean;
|
||||
}
|
||||
|
||||
// 카카오 콜백 응답 (JWT 토큰)
|
||||
export interface KakaoCallbackResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: KakaoCallbackUser;
|
||||
redirect_url: string;
|
||||
}
|
||||
|
||||
// 토큰 갱신 응답
|
||||
export interface TokenRefreshResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// 사용자 정보 응답
|
||||
export interface UserMeResponse {
|
||||
id: number;
|
||||
kakao_id: string;
|
||||
nickname: string;
|
||||
profile_image: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
|
|||
161
src/utils/api.ts
161
src/utils/api.ts
|
|
@ -13,6 +13,10 @@ import {
|
|||
VideoDownloadResponse,
|
||||
ImageUrlItem,
|
||||
ImageUploadResponse,
|
||||
KakaoLoginUrlResponse,
|
||||
KakaoCallbackResponse,
|
||||
TokenRefreshResponse,
|
||||
UserMeResponse,
|
||||
} from '../types/api';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||
|
|
@ -344,3 +348,160 @@ export async function waitForVideoComplete(
|
|||
|
||||
return poll();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 카카오 인증 API
|
||||
// ============================================
|
||||
|
||||
// 토큰 저장 키
|
||||
const ACCESS_TOKEN_KEY = 'castad_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'castad_refresh_token';
|
||||
|
||||
// 토큰 저장
|
||||
export function saveTokens(accessToken: string, refreshToken: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
// 토큰 가져오기
|
||||
export function getAccessToken(): string | null {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// 토큰 삭제
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// 인증 헤더 생성
|
||||
function getAuthHeader(): HeadersInit {
|
||||
const token = getAccessToken();
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 카카오 로그인 URL 획득
|
||||
export async function getKakaoLoginUrl(): Promise<KakaoLoginUrlResponse> {
|
||||
const response = await fetch(`${API_URL}/user/auth/kakao/login`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 카카오 콜백 처리 (인가 코드로 JWT 토큰 발급)
|
||||
export async function kakaoCallback(code: string): Promise<KakaoCallbackResponse> {
|
||||
const response = await fetch(`${API_URL}/user/auth/kakao/callback?code=${encodeURIComponent(code)}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: KakaoCallbackResponse = await response.json();
|
||||
|
||||
|
||||
// 토큰 저장
|
||||
saveTokens(data.access_token, data.refresh_token);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Access Token 갱신
|
||||
export async function refreshAccessToken(): Promise<TokenRefreshResponse> {
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/user/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// 리프레시 토큰도 만료된 경우 토큰 삭제
|
||||
if (response.status === 401) {
|
||||
clearTokens();
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TokenRefreshResponse = await response.json();
|
||||
|
||||
// 새 액세스 토큰 저장
|
||||
const currentRefreshToken = getRefreshToken();
|
||||
if (currentRefreshToken) {
|
||||
saveTokens(data.access_token, currentRefreshToken);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
export async function logout(): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/user/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
// 응답과 관계없이 로컬 토큰 삭제
|
||||
clearTokens();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 기기에서 로그아웃
|
||||
export async function logoutAll(): Promise<void> {
|
||||
const response = await fetch(`${API_URL}/user/auth/logout/all`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
// 응답과 관계없이 로컬 토큰 삭제
|
||||
clearTokens();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
export async function getUserMe(): Promise<UserMeResponse> {
|
||||
const response = await fetch(`${API_URL}/user/auth/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 로그인 여부 확인
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getAccessToken();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue