Compare commits
8 Commits
044fd21b2d
...
cf27da30b4
| Author | SHA1 | Date |
|---|---|---|
|
|
cf27da30b4 | |
|
|
8df5233fee | |
|
|
5796cce9c0 | |
|
|
310fde31d6 | |
|
|
324c78405a | |
|
|
00ea49b7ed | |
|
|
82dcda0038 | |
|
|
bdd52ed992 |
324
index.css
324
index.css
|
|
@ -780,6 +780,16 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-credits {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Sidebar Language Switch */
|
/* Sidebar Language Switch */
|
||||||
.sidebar-language-switch {
|
.sidebar-language-switch {
|
||||||
padding: 0 1rem 0.75rem;
|
padding: 0 1rem 0.75rem;
|
||||||
|
|
@ -7272,7 +7282,7 @@
|
||||||
.footer-content {
|
.footer-content {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7298,6 +7308,31 @@
|
||||||
line-height: 1.19;
|
line-height: 1.19;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #379599;
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link-divider {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #379599;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-right {
|
.footer-right {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -7990,6 +8025,25 @@
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #046266 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-thumb {
|
||||||
|
background: #046266;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-textarea::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #379599;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-placeholder {
|
.lyrics-placeholder {
|
||||||
|
|
@ -8042,6 +8096,15 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.charge-credits-link {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #AE72F9;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Video Generate Button */
|
/* Video Generate Button */
|
||||||
.btn-video-generate {
|
.btn-video-generate {
|
||||||
padding: 0.625rem 2.5rem;
|
padding: 0.625rem 2.5rem;
|
||||||
|
|
@ -8450,6 +8513,23 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #067C80;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #088a8e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Load More Button */
|
/* Load More Button */
|
||||||
.asset-load-more {
|
.asset-load-more {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -9327,6 +9407,232 @@
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-card {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.myinfo-credits-value {
|
||||||
|
color: #a6ffea;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-desc {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-charge-btn {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: linear-gradient(135deg, #a6ffea 0%, #7ee8cf 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #002224;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-credits-charge-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(166, 255, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup {
|
||||||
|
background: #132034;
|
||||||
|
border: 1px solid rgba(78, 205, 196, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-message {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-counter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-counter-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
width: 48px;
|
||||||
|
min-width: 48px;
|
||||||
|
height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-counter-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-input {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.6rem 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-input--center {
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 0;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-input--center::-webkit-inner-spin-button,
|
||||||
|
.myinfo-popup-input--center::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-input:focus {
|
||||||
|
border-color: rgba(78, 205, 196, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-textarea {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.6rem 0.875rem;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-textarea:focus {
|
||||||
|
border-color: rgba(78, 205, 196, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-cancel {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-close {
|
||||||
|
background: linear-gradient(135deg, #a6ffea 0%, #7ee8cf 100%);
|
||||||
|
color: #002224;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-close:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myinfo-popup-close:not(:disabled):hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
/* 내 비즈니스 카드 */
|
/* 내 비즈니스 카드 */
|
||||||
.myinfo-business-card {
|
.myinfo-business-card {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
|
@ -10565,7 +10871,21 @@
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-calendar-link {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress-calendar-link:hover {
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-progress-btn {
|
.upload-progress-btn {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>개인정보처리방침 - ADO2</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
||||||
|
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
||||||
|
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
||||||
|
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
.google-policy { background: #f0f7ff; border-left: 4px solid #4285f4; padding: 16px 20px; margin: 24px 0; border-radius: 4px; }
|
||||||
|
.google-policy p { color: #1a1a2e; margin: 0; }
|
||||||
|
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>개인정보처리방침 (Privacy Policy)</h1>
|
||||||
|
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 7일</p>
|
||||||
|
|
||||||
|
<p>㈜에이아이오투오(이하 "회사")는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")를 제공함에 있어 사용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 성실히 준수합니다.</p>
|
||||||
|
|
||||||
|
<h2>1. 수집하는 개인정보 항목 및 수집 방법</h2>
|
||||||
|
<p>회사는 서비스 제공을 위해 아래와 같은 개인정보를 수집합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Google OAuth를 통한 수집:</strong> 이름, 이메일 주소, 프로필 사진</li>
|
||||||
|
<li><strong>마케팅 자동화 서비스 제공용 (해당 권한 동의 시):</strong> YouTube 채널 데이터, Google 광고 계정 데이터 등 연동된 마케팅 채널 데이터</li>
|
||||||
|
<li><strong>서비스 이용 과정에서 자동 수집:</strong> 접속 IP, 쿠키, 서비스 이용 기록</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>2. 개인정보의 수집 및 이용 목적</h2>
|
||||||
|
<ul>
|
||||||
|
<li>AI 마케팅 콘텐츠 자동 생성 및 배포 서비스 제공</li>
|
||||||
|
<li>계정 연동 및 사용자 식별·본인 인증</li>
|
||||||
|
<li>서비스 품질 개선 및 통계 분석</li>
|
||||||
|
<li>공지사항 전달 및 고객 상담</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="google-policy">
|
||||||
|
<p><strong>[Google API 서비스 사용자 데이터 정책 준수]</strong><br><br>
|
||||||
|
㈜에이아이오투오가 운영하는 ADO2 서비스가 Google API로부터 수신한 정보의 사용 및 타 앱으로의 전송은,
|
||||||
|
<a href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">Google API 서비스 사용자 데이터 정책</a>의
|
||||||
|
제한적 사용(Limited Use) 요건을 포함한 모든 정책을 엄격히 준수합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>3. 개인정보의 보유 및 이용 기간</h2>
|
||||||
|
<p>원칙적으로 회원 탈퇴 또는 개인정보 수집·이용 목적이 달성된 후에는 해당 정보를 지체 없이 파기합니다. 단, 관련 법령에 따라 보존이 필요한 경우 해당 기간 동안 보관합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li>전자상거래 관련 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)</li>
|
||||||
|
<li>접속 로그 기록: 3개월 (통신비밀보호법)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>4. 개인정보의 제3자 제공</h2>
|
||||||
|
<p>회사는 사용자의 사전 동의 없이 개인정보를 외부에 제공하지 않습니다. 다만, 법령에 의거한 수사기관 등의 적법한 요청이 있는 경우는 예외로 합니다.</p>
|
||||||
|
|
||||||
|
<h2>5. 정보주체의 권리 및 행사 방법</h2>
|
||||||
|
<p>사용자는 언제든지 자신의 개인정보에 대한 열람, 수정, 삭제, 처리 정지를 요청할 수 있습니다. 서비스 내 계정 설정에서 직접 처리하거나 아래 문의처로 연락해 주시기 바랍니다.</p>
|
||||||
|
|
||||||
|
<h2>6. 개인정보 보호책임자 및 문의처</h2>
|
||||||
|
<p>개인정보 보호와 관련된 불만 처리 및 피해 구제에 관한 사항은 아래로 문의해 주시기 바랍니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
|
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
||||||
|
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 방침은 2026년 5월 7일부터 시행됩니다.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>서비스 약관 - ADO2</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.8; color: #333; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
|
||||||
|
h1 { font-size: 28px; border-bottom: 2px solid #eee; padding-bottom: 12px; margin-bottom: 30px; }
|
||||||
|
h2 { font-size: 19px; margin-top: 40px; color: #111; }
|
||||||
|
p, li { font-size: 15px; color: #555; margin-bottom: 10px; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
.updated { color: #888; font-size: 14px; margin-bottom: 30px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>서비스 이용약관 (Terms of Service)</h1>
|
||||||
|
<p class="updated"><strong>시행일:</strong> 2026년 5월 7일 | <strong>최종 수정일:</strong> 2026년 5월 7일</p>
|
||||||
|
|
||||||
|
<h2>제 1 조 (목적)</h2>
|
||||||
|
<p>본 약관은 ㈜에이아이오투오(이하 "회사")가 제공하는 AI 마케팅 자동화 서비스 ADO2(이하 "서비스")의 이용과 관련하여, 회사와 이용자(이하 "회원") 간의 권리, 의무 및 책임사항을 규정함을 목적으로 합니다.</p>
|
||||||
|
|
||||||
|
<h2>제 2 조 (용어의 정의)</h2>
|
||||||
|
<ul>
|
||||||
|
<li>"서비스"라 함은 회사가 제공하는 ADO2 AI 마케팅 자동화 플랫폼 및 관련 제반 기능을 의미합니다.</li>
|
||||||
|
<li>"회원"이라 함은 본 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 자를 말합니다.</li>
|
||||||
|
<li>"콘텐츠"라 함은 서비스 내에서 AI가 생성하거나 회원이 등록하는 텍스트, 이미지, 음악, 영상 등 일체의 자료를 말합니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>제 3 조 (약관의 효력 및 변경)</h2>
|
||||||
|
<p>회사는 본 약관의 내용을 서비스 화면에 게시하며, 관련 법령을 위배하지 않는 범위에서 약관을 개정할 수 있습니다. 약관이 변경되는 경우 시행일 7일 전부터 공지합니다.</p>
|
||||||
|
|
||||||
|
<h2>제 4 조 (서비스의 제공 및 변경)</h2>
|
||||||
|
<p>회사는 AI 기반 마케팅 콘텐츠(가사, 이미지, 영상 등) 자동 생성, Google·YouTube 등 외부 플랫폼 연동, SNS 자동 배포 등의 서비스를 제공합니다. 운영상·기술상의 필요에 따라 서비스의 전부 또는 일부를 변경할 수 있습니다.</p>
|
||||||
|
|
||||||
|
<h2>제 5 조 (회원의 의무)</h2>
|
||||||
|
<ul>
|
||||||
|
<li>타인의 Google 계정 등 외부 서비스 계정을 무단으로 도용하여 서비스를 이용해서는 안 됩니다.</li>
|
||||||
|
<li>스팸 발송, API 한도 고의 초과, 허위 정보 등록 등 비정상적인 방법으로 서비스를 이용해서는 안 됩니다.</li>
|
||||||
|
<li>회사 또는 제3자의 지식재산권을 침해해서는 안 됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>제 6 조 (외부 API 연동 및 데이터 활용)</h2>
|
||||||
|
<p>서비스는 Google, YouTube, Naver 등의 제3자 API를 활용하여 마케팅 자동화 기능을 제공합니다. 회원은 각 외부 서비스의 이용약관 및 정책을 준수할 의무가 있으며, 외부 API 제공사의 정책 변경으로 인한 서비스 제한에 대해 회사는 면책됩니다.</p>
|
||||||
|
|
||||||
|
<h2>제 7 조 (AI 생성 콘텐츠의 권리)</h2>
|
||||||
|
<p>서비스 내에서 AI가 생성한 콘텐츠에 대한 권리 관계는 관련 법령 및 회사의 별도 정책에 따릅니다. 회원이 직접 입력한 정보(매장 URL, 상호명 등)를 기반으로 생성된 콘텐츠에 대한 책임은 회원에게 있습니다.</p>
|
||||||
|
|
||||||
|
<h2>제 8 조 (책임 제한)</h2>
|
||||||
|
<p>회사는 천재지변, 외부 플랫폼(Google, YouTube, Naver 등)의 장애, 통신 장애 등 불가항력으로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</p>
|
||||||
|
|
||||||
|
<h2>제 9 조 (준거법 및 재판관할)</h2>
|
||||||
|
<p>본 약관과 관련된 분쟁은 대한민국 법을 준거법으로 하며, 소송은 회사의 소재지를 관할하는 법원에 제소합니다.</p>
|
||||||
|
|
||||||
|
<h2>문의처</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>회사명:</strong> ㈜에이아이오투오</li>
|
||||||
|
<li><strong>서비스명:</strong> ADO2</li>
|
||||||
|
<li><strong>이메일:</strong> o2oteam@o2o.kr</li>
|
||||||
|
<li><strong>웹사이트:</strong> https://demo.castad.net</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top:40px; font-size:14px; color:#999;">본 약관은 2026년 5월 7일부터 시행됩니다.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -10,6 +10,11 @@ const Footer: React.FC = () => {
|
||||||
<div className="footer-left">
|
<div className="footer-left">
|
||||||
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
<img src="/assets/images/ado2-sidebar-logo.svg" alt="ADO2" className="footer-logo" />
|
||||||
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
<p className="footer-copyright">Copyright ⓒ O2O Inc. All rights reserved</p>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a href="https://demo.castad.net/privacy.html" rel="noopener noreferrer" className="footer-link">{t('footer.privacyPolicy')}</a>
|
||||||
|
<span className="footer-link-divider">|</span>
|
||||||
|
<a href="https://demo.castad.net/terms.html" rel="noopener noreferrer" className="footer-link">{t('footer.termsOfService')}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-right">
|
<div className="footer-right">
|
||||||
<p className="footer-info">{t('footer.company')}</p>
|
<p className="footer-info">{t('footer.company')}</p>
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,10 @@ interface SidebarProps {
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
userInfo?: UserMeResponse | null;
|
userInfo?: UserMeResponse | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
credits?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout }) => {
|
const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userInfo, onLogout, credits }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
|
|
@ -180,6 +181,9 @@ const Sidebar: React.FC<SidebarProps> = ({ activeItem, onNavigate, onHome, userI
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
<p className="profile-name">{userInfo?.nickname || t('sidebar.defaultUser')}</p>
|
||||||
|
{credits !== null && credits !== undefined && (
|
||||||
|
<p className="profile-credits">{t('sidebar.credits', { count: credits })}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface SocialPostingModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
video: VideoListItem | null;
|
video: VideoListItem | null;
|
||||||
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrivacyType = 'public' | 'unlisted' | 'private';
|
type PrivacyType = 'public' | 'unlisted' | 'private';
|
||||||
|
|
@ -116,7 +117,8 @@ const MiniCalendar: React.FC<{
|
||||||
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
video
|
video,
|
||||||
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tutorial = useTutorial();
|
const tutorial = useTutorial();
|
||||||
|
|
@ -150,6 +152,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 업로드 정보 (모달이 닫힌 후에도 유지)
|
// 업로드 정보 (모달이 닫힌 후에도 유지)
|
||||||
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
||||||
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
||||||
|
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -217,7 +220,9 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
const activeAccounts = response.accounts?.filter(acc => acc.is_active) || [];
|
||||||
setSocialAccounts(activeAccounts);
|
setSocialAccounts(activeAccounts);
|
||||||
if (activeAccounts.length > 0) {
|
if (activeAccounts.length > 0) {
|
||||||
setSelectedChannel(activeAccounts[0].platform_user_id);
|
const lastUsed = localStorage.getItem('lastUsedSocialChannel');
|
||||||
|
const defaultAccount = activeAccounts.find(acc => acc.platform_user_id === lastUsed) || activeAccounts[0];
|
||||||
|
setSelectedChannel(defaultAccount.platform_user_id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TokenExpiredError) {
|
if (error instanceof TokenExpiredError) {
|
||||||
|
|
@ -298,6 +303,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
|
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
|
||||||
setUploadVideoTitle(title.trim());
|
setUploadVideoTitle(title.trim());
|
||||||
setUploadChannelName(selectedAcc.display_name);
|
setUploadChannelName(selectedAcc.display_name);
|
||||||
|
setUploadIsScheduled(publishTime === 'schedule');
|
||||||
setShowUploadProgress(true);
|
setShowUploadProgress(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -335,6 +341,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
throw new Error(uploadResponse.message || t('social.uploadStartFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('lastUsedSocialChannel', selectedAcc.platform_user_id);
|
||||||
|
|
||||||
if (publishTime === 'schedule') {
|
if (publishTime === 'schedule') {
|
||||||
// 예약 업로드: 폴링 없이 바로 완료 처리
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
|
|
@ -397,6 +405,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
setUploadErrorMessage(undefined);
|
setUploadErrorMessage(undefined);
|
||||||
setUploadVideoTitle('');
|
setUploadVideoTitle('');
|
||||||
setUploadChannelName('');
|
setUploadChannelName('');
|
||||||
|
setUploadIsScheduled(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -425,7 +434,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
channelName={uploadChannelName || selectedAccount?.display_name || ''}
|
||||||
youtubeUrl={uploadYoutubeUrl}
|
youtubeUrl={uploadYoutubeUrl}
|
||||||
errorMessage={uploadErrorMessage}
|
errorMessage={uploadErrorMessage}
|
||||||
isScheduled={publishTime === 'schedule'}
|
isScheduled={uploadIsScheduled}
|
||||||
|
onGoToCalendar={onGoToCalendar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface UploadProgressModalProps {
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isScheduled?: boolean;
|
isScheduled?: boolean;
|
||||||
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
|
|
@ -26,6 +27,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
youtubeUrl,
|
youtubeUrl,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isScheduled = false,
|
isScheduled = false,
|
||||||
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
@ -151,9 +153,16 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="upload-progress-footer">
|
<div className="upload-progress-footer">
|
||||||
{canClose ? (
|
{canClose ? (
|
||||||
|
<>
|
||||||
<button className="upload-progress-btn primary" onClick={onClose}>
|
<button className="upload-progress-btn primary" onClick={onClose}>
|
||||||
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
|
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
|
||||||
</button>
|
</button>
|
||||||
|
{status === 'completed' && onGoToCalendar && (
|
||||||
|
<span className="upload-progress-calendar-link" onClick={() => { onClose(); onGoToCalendar(); }}>
|
||||||
|
{t('upload.goToCalendar')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
|
<p className="upload-progress-note">{t('upload.doNotClose')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"myContents": "My Contents",
|
"myContents": "My Contents",
|
||||||
"myInfo": "My Info",
|
"myInfo": "My Info",
|
||||||
"defaultUser": "User",
|
"defaultUser": "User",
|
||||||
|
"credits": "Credits left: {{count}}",
|
||||||
"loggingOut": "Logging out...",
|
"loggingOut": "Logging out...",
|
||||||
"logout": "Log Out",
|
"logout": "Log Out",
|
||||||
"tutorialRestart": "Restart Tutorial",
|
"tutorialRestart": "Restart Tutorial",
|
||||||
|
|
@ -97,7 +98,9 @@
|
||||||
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
"headquarters": "HQ : 41593 Unicorn Lab Daegu A05, 5F, 111 Oksan-ro, Buk-gu, Daegu, Korea",
|
||||||
"researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
"researchCenter": "R&D : 13453 Rooms 504-505 (East), KT Pangyo Bldg, 32 Geumto-ro, Sujeong-gu, Seongnam-si, Gyeonggi-do, Korea",
|
||||||
"phone": "Tel : 070-4260-8310 | 010-2755-6463",
|
"phone": "Tel : 070-4260-8310 | 010-2755-6463",
|
||||||
"email": "Email : o2oteam@o2o.kr"
|
"email": "Email : o2oteam@o2o.kr",
|
||||||
|
"privacyPolicy": "Privacy Policy",
|
||||||
|
"termsOfService": "Terms of Service"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"title": "Social Media Posting",
|
"title": "Social Media Posting",
|
||||||
|
|
@ -149,7 +152,8 @@
|
||||||
"viewOnYoutube": "View on YouTube",
|
"viewOnYoutube": "View on YouTube",
|
||||||
"confirm": "OK",
|
"confirm": "OK",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"doNotClose": "Upload is in progress. Do not close this window."
|
"doNotClose": "Upload is in progress. Do not close this window.",
|
||||||
|
"goToCalendar": "View in Calendar"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|
@ -250,7 +254,10 @@
|
||||||
"musicGenerationFailed": "Music generation failed.",
|
"musicGenerationFailed": "Music generation failed.",
|
||||||
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
"multipleRetryFailed": "Music generation failed after multiple attempts. Please try again.",
|
||||||
"musicGenerationError": "An error occurred during music generation.",
|
"musicGenerationError": "An error occurred during music generation.",
|
||||||
"songRegenerationError": "An error occurred during music regeneration."
|
"songRegenerationError": "An error occurred during music regeneration.",
|
||||||
|
"creditsRemaining": "{{count}} left",
|
||||||
|
"creditsExhausted": "Not enough credits.",
|
||||||
|
"chargeCredits": "Purchase credits"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -426,7 +433,22 @@
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
"disconnectAccount": "Disconnect",
|
"disconnectAccount": "Disconnect",
|
||||||
"loadingAccounts": "Loading account information...",
|
"loadingAccounts": "Loading account information...",
|
||||||
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page."
|
"youtubeExpiredAlert": "YouTube authentication has expired. Redirecting to reconnect page.",
|
||||||
|
"creditsTitle": "Credit Balance",
|
||||||
|
"creditsLoading": "Loading...",
|
||||||
|
"creditsLabel": "Available Credits",
|
||||||
|
"creditsUnit": "credits",
|
||||||
|
"creditsDesc": "You can request a top-up from the admin if you run low on credits.",
|
||||||
|
"creditsChargeBtn": "Top Up",
|
||||||
|
"chargePopupTitle": "Credit Top-up Request",
|
||||||
|
"chargeAmountLabel": "Credits to Request",
|
||||||
|
"chargeNoteLabel": "Additional Notes",
|
||||||
|
"chargeNotePlaceholder": "Enter any message for the admin",
|
||||||
|
"chargeCancel": "Cancel",
|
||||||
|
"chargeSubmit": "Submit",
|
||||||
|
"chargeSubmitting": "Submitting...",
|
||||||
|
"chargeSuccess": "Your top-up request has been submitted!",
|
||||||
|
"chargeConfirm": "OK"
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 Contents",
|
"title": "ADO2 Contents",
|
||||||
|
|
@ -474,7 +496,11 @@
|
||||||
"generateContent": "Generate Content",
|
"generateContent": "Generate Content",
|
||||||
"pageDescBefore": " reveals ",
|
"pageDescBefore": " reveals ",
|
||||||
"pageDescAfter": "'s core strategy.",
|
"pageDescAfter": "'s core strategy.",
|
||||||
"loadingTitle": "Analyzing Brand"
|
"loadingTitle": "Analyzing Brand",
|
||||||
|
"loadingStep1": "Collecting brand information...",
|
||||||
|
"loadingStep2": "Analyzing collected data...",
|
||||||
|
"loadingStep3": "Deriving key strategies...",
|
||||||
|
"loadingStep4": "Organizing results..."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "Go Back",
|
"back": "Go Back",
|
||||||
|
|
@ -490,13 +516,13 @@
|
||||||
"title": "Content Calendar",
|
"title": "Content Calendar",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"completed": "Completed",
|
"completed": "Done",
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"completed": "Completed",
|
"completed": "Done",
|
||||||
"scheduled": "Scheduled",
|
"scheduled": "Planned",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
},
|
},
|
||||||
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
"months": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],
|
||||||
|
|
@ -523,7 +549,7 @@
|
||||||
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
"loginUrlFailed": "Failed to get login URL. Please try again.",
|
||||||
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
|
"invalidUrl": "Invalid URL. Please enter a Naver Map URL.",
|
||||||
"analysisError": "An error occurred during analysis. Please try again.",
|
"analysisError": "An error occurred during analysis. Please try again.",
|
||||||
"autocompleteError": "Failed to retrieve business information.",
|
"autocompleteError": "Failed to retrieve business information. Please enter the URL directly.",
|
||||||
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
|
||||||
"pageComingSoon": "{{page}} page is coming soon."
|
"pageComingSoon": "{{page}} page is coming soon."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"myContents": "내 콘텐츠",
|
"myContents": "내 콘텐츠",
|
||||||
"myInfo": "내 정보",
|
"myInfo": "내 정보",
|
||||||
"defaultUser": "사용자",
|
"defaultUser": "사용자",
|
||||||
|
"credits": "보유 크레딧: {{count}}",
|
||||||
"loggingOut": "로그아웃 중...",
|
"loggingOut": "로그아웃 중...",
|
||||||
"logout": "로그아웃",
|
"logout": "로그아웃",
|
||||||
"tutorialRestart": "튜토리얼 다시 보기",
|
"tutorialRestart": "튜토리얼 다시 보기",
|
||||||
|
|
@ -97,7 +98,9 @@
|
||||||
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
"headquarters": "본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호",
|
||||||
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
"researchCenter": "연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)",
|
||||||
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
|
"phone": "전화 : 070-4260-8310 | 010-2755-6463",
|
||||||
"email": "이메일 : o2oteam@o2o.kr"
|
"email": "이메일 : o2oteam@o2o.kr",
|
||||||
|
"privacyPolicy": "개인정보처리방침",
|
||||||
|
"termsOfService": "서비스 약관"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"title": "소셜 미디어 포스팅",
|
"title": "소셜 미디어 포스팅",
|
||||||
|
|
@ -149,7 +152,8 @@
|
||||||
"viewOnYoutube": "YouTube에서 보기",
|
"viewOnYoutube": "YouTube에서 보기",
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
|
"goToCalendar": "캘린더에서 확인"
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|
@ -250,7 +254,10 @@
|
||||||
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
"musicGenerationFailed": "음악 생성에 실패했습니다.",
|
||||||
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
|
||||||
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
|
||||||
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다."
|
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다.",
|
||||||
|
"creditsRemaining": "잔여 {{count}}",
|
||||||
|
"creditsExhausted": "크레딧이 부족합니다.",
|
||||||
|
"chargeCredits": "크레딧 충전하기"
|
||||||
},
|
},
|
||||||
"completion": {
|
"completion": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -426,7 +433,22 @@
|
||||||
"connected": "연결됨",
|
"connected": "연결됨",
|
||||||
"disconnectAccount": "연결 해제",
|
"disconnectAccount": "연결 해제",
|
||||||
"loadingAccounts": "계정 정보를 불러오는 중...",
|
"loadingAccounts": "계정 정보를 불러오는 중...",
|
||||||
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
|
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
|
||||||
|
"creditsTitle": "크레딧 현황",
|
||||||
|
"creditsLoading": "로딩 중...",
|
||||||
|
"creditsLabel": "보유 크레딧",
|
||||||
|
"creditsUnit": "크레딧",
|
||||||
|
"creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.",
|
||||||
|
"creditsChargeBtn": "충전하기",
|
||||||
|
"chargePopupTitle": "크레딧 충전 요청",
|
||||||
|
"chargeAmountLabel": "충전 크레딧",
|
||||||
|
"chargeNoteLabel": "기타 내용",
|
||||||
|
"chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요",
|
||||||
|
"chargeCancel": "취소",
|
||||||
|
"chargeSubmit": "요청하기",
|
||||||
|
"chargeSubmitting": "요청 중...",
|
||||||
|
"chargeSuccess": "충전 요청이 완료 되었습니다!",
|
||||||
|
"chargeConfirm": "확인"
|
||||||
},
|
},
|
||||||
"ado2Contents": {
|
"ado2Contents": {
|
||||||
"title": "ADO2 콘텐츠",
|
"title": "ADO2 콘텐츠",
|
||||||
|
|
@ -474,7 +496,11 @@
|
||||||
"generateContent": "콘텐츠 생성",
|
"generateContent": "콘텐츠 생성",
|
||||||
"pageDescBefore": "을 통해 도출된 ",
|
"pageDescBefore": "을 통해 도출된 ",
|
||||||
"pageDescAfter": "의 핵심 전략입니다.",
|
"pageDescAfter": "의 핵심 전략입니다.",
|
||||||
"loadingTitle": "브랜드 분석 중"
|
"loadingTitle": "브랜드 분석 중",
|
||||||
|
"loadingStep1": "브랜드 정보를 수집하는 중...",
|
||||||
|
"loadingStep2": "수집한 데이터를 분석하는 중...",
|
||||||
|
"loadingStep3": "핵심 전략을 도출하는 중...",
|
||||||
|
"loadingStep4": "결과를 정리하는 중..."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
|
|
@ -502,7 +528,7 @@
|
||||||
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
"months": ["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],
|
||||||
"days": ["일","월","화","수","목","금","토"],
|
"days": ["일","월","화","수","목","금","토"],
|
||||||
"yearMonth": "{{year}}년 {{month}}",
|
"yearMonth": "{{year}}년 {{month}}",
|
||||||
"monthDay": "{{month}}월 {{day}}",
|
"monthDay": "{{month}} {{day}}일",
|
||||||
"loading": "불러오는 중...",
|
"loading": "불러오는 중...",
|
||||||
"noResults": "최근 결과 없음",
|
"noResults": "최근 결과 없음",
|
||||||
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
"noResultsDesc": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",
|
||||||
|
|
@ -523,7 +549,7 @@
|
||||||
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
"loginUrlFailed": "로그인 URL을 가져오는데 실패했습니다. 다시 시도해주세요.",
|
||||||
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
|
"invalidUrl": "유효하지 않은 URL입니다. 네이버 지도 URL을 입력해주세요.",
|
||||||
"analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
|
"analysisError": "분석 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"autocompleteError": "업체 정보 조회에 실패했습니다.",
|
"autocompleteError": "업체 정보 조회에 실패했습니다. URL을 직접 입력해주세요.",
|
||||||
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
|
||||||
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isComplete]);
|
}, [isComplete]);
|
||||||
|
|
||||||
|
const getStepMessage = (p: number) => {
|
||||||
|
if (p < 30) return t('analysis.loadingStep1');
|
||||||
|
if (p < 50) return t('analysis.loadingStep2');
|
||||||
|
if (p < 80) return t('analysis.loadingStep3');
|
||||||
|
return t('analysis.loadingStep4');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
<div className="loading-content">
|
<div className="loading-content">
|
||||||
|
|
@ -67,6 +74,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
|
||||||
</div>
|
</div>
|
||||||
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
<span className="loading-progress-text">{Math.floor(progress)}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="loading-step-message">{getStepMessage(progress)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import SocialPostingModal from '../../components/SocialPostingModal';
|
||||||
|
|
||||||
interface ADO2ContentsPageProps {
|
interface ADO2ContentsPageProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
onNavigate?: (item: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
const [videos, setVideos] = useState<VideoListItem[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
|
|
@ -295,6 +296,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
|
||||||
isOpen={uploadModalOpen}
|
isOpen={uploadModalOpen}
|
||||||
onClose={handleUploadModalClose}
|
onClose={handleUploadModalClose}
|
||||||
video={uploadTargetVideo}
|
video={uploadTargetVideo}
|
||||||
|
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ interface CompletionContentProps {
|
||||||
songTaskId: string | null;
|
songTaskId: string | null;
|
||||||
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
|
||||||
onVideoProgressChange?: (progress: number) => void;
|
onVideoProgressChange?: (progress: number) => void;
|
||||||
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
||||||
|
|
@ -32,7 +33,8 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
onBack,
|
onBack,
|
||||||
songTaskId,
|
songTaskId,
|
||||||
onVideoStatusChange,
|
onVideoStatusChange,
|
||||||
onVideoProgressChange
|
onVideoProgressChange,
|
||||||
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||||
|
|
@ -45,18 +47,20 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const tutorial = useTutorial();
|
const tutorial = useTutorial();
|
||||||
|
const tutorialIsActiveRef = useRef(tutorial.isActive);
|
||||||
|
tutorialIsActiveRef.current = tutorial.isActive;
|
||||||
|
|
||||||
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isComplete = videoStatus === 'complete';
|
const isComplete = videoStatus === 'complete';
|
||||||
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
const isProcessing = videoStatus === 'generating' || videoStatus === 'polling';
|
||||||
|
|
||||||
if (isProcessing && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
if (isProcessing && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.GENERATING)) {
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
tutorial.startTutorial(TUTORIAL_KEYS.GENERATING);
|
||||||
} else if (isComplete && !tutorial.isActive && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
} else if (isComplete && !tutorialIsActiveRef.current && !tutorial.hasSeen(TUTORIAL_KEYS.COMPLETION)) {
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
tutorial.startTutorial(TUTORIAL_KEYS.COMPLETION);
|
||||||
}
|
}
|
||||||
}, [videoStatus, tutorial, tutorial.isActive]);
|
}, [videoStatus]);
|
||||||
|
|
||||||
// 소셜 미디어 포스팅 모달
|
// 소셜 미디어 포스팅 모달
|
||||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||||
|
|
@ -510,6 +514,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
<SocialPostingModal
|
<SocialPostingModal
|
||||||
isOpen={showSocialModal}
|
isOpen={showSocialModal}
|
||||||
onClose={handleCloseSocialConnect}
|
onClose={handleCloseSocialConnect}
|
||||||
|
onGoToCalendar={onGoToCalendar}
|
||||||
video={videoUrl && videoDbId ? {
|
video={videoUrl && videoDbId ? {
|
||||||
video_id: videoDbId,
|
video_id: videoDbId,
|
||||||
store_name: songCompletionData?.businessName || '',
|
store_name: songCompletionData?.businessName || '',
|
||||||
|
|
|
||||||
|
|
@ -548,9 +548,19 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 채널 아이콘 + 제목 */}
|
{/* 채널 아이콘 + 채널명 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<PlatformIcon platform={item.platform} size={18} />
|
<PlatformIcon platform={item.platform} size={16} />
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 500, fontSize: 12,
|
||||||
|
color: '#9bcacc', lineHeight: 1,
|
||||||
|
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{item.platform_username || item.platform_user_id || item.channel_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
<span style={{
|
<span style={{
|
||||||
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
|
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
|
||||||
color: '#e5f1f2', lineHeight: 1.4,
|
color: '#e5f1f2', lineHeight: 1.4,
|
||||||
|
|
@ -559,7 +569,6 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 실패 메시지 */}
|
{/* 실패 메시지 */}
|
||||||
{isFailed && item.error_message && (
|
{isFailed && item.error_message && (
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ const DashboardContent: React.FC<DashboardContentProps> = ({ onNavigate }) => {
|
||||||
|
|
||||||
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
// showMockData=true면 전체 mock 강제, 아니면 API 우선 / isEmptyState 시 mock 폴백
|
||||||
const useReal = !showMockData && !isEmptyState;
|
const useReal = !showMockData && !isEmptyState;
|
||||||
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.slice(0, -1).some((m: ContentMetric) => m.value > 0);
|
const hasRealContentMetrics = useReal && !!dashboardData?.contentMetrics?.some((m: ContentMetric) => m.value > 0);
|
||||||
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
|
const contentMetrics = hasRealContentMetrics ? dashboardData!.contentMetrics : MOCK_CONTENT_METRICS;
|
||||||
|
|
||||||
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
|
// 블러 조건: 1)계정 미연결 2)업로드 영상 없음 3)데이터 없음 4)에러 있음 5)실제 지표 없음
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
|
|
@ -14,7 +14,7 @@ import ContentCalendarContent from './ContentCalendarContent';
|
||||||
import LoadingSection from '../Analysis/LoadingSection';
|
import LoadingSection from '../Analysis/LoadingSection';
|
||||||
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
import AnalysisResultSection from '../Analysis/AnalysisResultSection';
|
||||||
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
import { ImageItem, CrawlingResponse, UserMeResponse } from '../../types/api';
|
||||||
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, clearTokens } from '../../utils/api';
|
import { crawlUrl, autocomplete, AutocompleteRequest, getUserMe, getUserCredits, clearTokens } from '../../utils/api';
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
||||||
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay';
|
||||||
|
|
@ -117,14 +117,26 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||||
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
|
||||||
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
const [userInfo, setUserInfo] = useState<UserMeResponse | null>(null);
|
||||||
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
|
const [myInfoInitialTab, setMyInfoInitialTab] = useState<'basic' | 'payment' | 'business' | undefined>(undefined);
|
||||||
const tutorial = useTutorial();
|
const tutorial = useTutorial();
|
||||||
|
|
||||||
// 로그인 직후 사용자 정보 조회
|
const refreshCredits = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { credits } = await getUserCredits();
|
||||||
|
setCredits(credits);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh credits:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 로그인 직후 사용자 정보 + 크레딧 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getUserMe();
|
const [data, creditsData] = await Promise.all([getUserMe(), getUserCredits()]);
|
||||||
setUserInfo(data);
|
setUserInfo(data);
|
||||||
|
setCredits(creditsData.credits);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user info:', error);
|
console.error('Failed to fetch user info:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +248,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
setIsAnalysisComplete(true);
|
setIsAnalysisComplete(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Autocomplete error:', err);
|
console.error('Autocomplete error:', err);
|
||||||
setAnalysisError(err instanceof Error ? err.message : t('app.autocompleteError'));
|
const msg = err instanceof Error ? err.message : '';
|
||||||
|
setAnalysisError(/^HTTP error!/.test(msg) ? t('app.autocompleteError') : (msg || t('app.autocompleteError')));
|
||||||
goToWizardStep(-2); // URL 입력으로 돌아가기
|
goToWizardStep(-2); // URL 입력으로 돌아가기
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -355,8 +368,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
}
|
}
|
||||||
}, [activeItem]);
|
}, [activeItem]);
|
||||||
|
|
||||||
|
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
|
||||||
|
const handleGoToPayment = () => {
|
||||||
|
setMyInfoInitialTab('payment');
|
||||||
|
setActiveItem('내 정보');
|
||||||
|
};
|
||||||
|
|
||||||
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
|
||||||
const handleNavigate = (item: string) => {
|
const handleNavigate = (item: string) => {
|
||||||
|
setMyInfoInitialTab(undefined);
|
||||||
if (item === '새 프로젝트 만들기') {
|
if (item === '새 프로젝트 만들기') {
|
||||||
// 기존 프로젝트 데이터 초기화
|
// 기존 프로젝트 데이터 초기화
|
||||||
clearAllProjectStorage();
|
clearAllProjectStorage();
|
||||||
|
|
@ -373,6 +393,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
setAnalysisError(null);
|
setAnalysisError(null);
|
||||||
}
|
}
|
||||||
setActiveItem(item);
|
setActiveItem(item);
|
||||||
|
refreshCredits();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||||
|
|
@ -443,6 +464,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
mId={analysisData?.m_id ?? 0}
|
mId={analysisData?.m_id ?? 0}
|
||||||
videoGenerationStatus={videoGenerationStatus}
|
videoGenerationStatus={videoGenerationStatus}
|
||||||
videoGenerationProgress={videoGenerationProgress}
|
videoGenerationProgress={videoGenerationProgress}
|
||||||
|
onGoToPayment={handleGoToPayment}
|
||||||
onStatusChange={(status: string) => {
|
onStatusChange={(status: string) => {
|
||||||
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
|
||||||
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
|
||||||
|
|
@ -467,6 +489,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
|
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
@ -484,12 +507,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<ADO2ContentsPage
|
<ADO2ContentsPage
|
||||||
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
onBack={() => setActiveItem('새 프로젝트 만들기')}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case '콘텐츠 캘린더':
|
case '콘텐츠 캘린더':
|
||||||
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
return <ContentCalendarContent onNavigate={handleNavigate} />;
|
||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent />;
|
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
||||||
if (wizardStep === 0 || wizardStep === -1) {
|
if (wizardStep === 0 || wizardStep === -1) {
|
||||||
|
|
@ -572,7 +596,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="analysis-page-wrapper">
|
<div className="analysis-page-wrapper">
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} />
|
||||||
)}
|
)}
|
||||||
<main className="analysis-page-main">
|
<main className="analysis-page-main">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
@ -585,7 +609,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
<div className={`flex w-full bg-[#002224] text-white ${needsScroll ? 'min-h-[100dvh]' : 'h-[100dvh] overflow-hidden'}`}>
|
||||||
{showSidebar && (
|
{showSidebar && (
|
||||||
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} />
|
<Sidebar activeItem={activeItem} onNavigate={handleNavigate} onHome={handleHome} userInfo={userInfo} onLogout={handleLogout} credits={credits} />
|
||||||
)}
|
)}
|
||||||
{tutorialUI}
|
{tutorialUI}
|
||||||
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
<div className={`flex-1 relative pl-0 md:pl-0 ${needsScroll ? 'overflow-auto' : 'h-full overflow-hidden'}`}>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,53 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
|
import { getSocialAccounts, getYouTubeConnectUrl, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect, getUserCredits, requestCreditCharge } from '../../utils/api';
|
||||||
import { SocialAccount } from '../../types/api';
|
import { SocialAccount } from '../../types/api';
|
||||||
|
|
||||||
type TabType = 'basic' | 'payment' | 'business';
|
type TabType = 'basic' | 'payment' | 'business';
|
||||||
|
|
||||||
const MyInfoContent: React.FC = () => {
|
interface MyInfoContentProps {
|
||||||
|
initialTab?: TabType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('business');
|
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
|
||||||
const [businessUrl, setBusinessUrl] = useState('');
|
const [businessUrl, setBusinessUrl] = useState('');
|
||||||
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
|
||||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState<string | null>(null);
|
const [isConnecting, setIsConnecting] = useState<string | null>(null);
|
||||||
|
const [credits, setCredits] = useState<number | null>(null);
|
||||||
|
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
|
||||||
|
const [showChargePopup, setShowChargePopup] = useState(false);
|
||||||
|
const [chargeAmount, setChargeAmount] = useState('');
|
||||||
|
const [chargeNote, setChargeNote] = useState('');
|
||||||
|
const [isRequesting, setIsRequesting] = useState(false);
|
||||||
|
const [chargeSuccess, setChargeSuccess] = useState(false);
|
||||||
|
|
||||||
|
// initialTab 변경 시 탭 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialTab) setActiveTab(initialTab);
|
||||||
|
}, [initialTab]);
|
||||||
|
|
||||||
|
// 크레딧 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'payment') {
|
||||||
|
loadCredits();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const loadCredits = async () => {
|
||||||
|
setIsLoadingCredits(true);
|
||||||
|
try {
|
||||||
|
const data = await getUserCredits();
|
||||||
|
setCredits(data.credits);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load credits:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingCredits(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 소셜 계정 목록 로드
|
// 소셜 계정 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -84,6 +119,74 @@ const MyInfoContent: React.FC = () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showChargePopup && (
|
||||||
|
<div className="myinfo-popup-overlay" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>
|
||||||
|
<div className="myinfo-popup" onClick={e => e.stopPropagation()}>
|
||||||
|
{chargeSuccess ? (
|
||||||
|
<>
|
||||||
|
<p className="myinfo-popup-message">{t('myInfo.chargeSuccess')}</p>
|
||||||
|
<button className="myinfo-popup-close" onClick={() => { setShowChargePopup(false); setChargeSuccess(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeConfirm')}</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h3 className="myinfo-popup-title">{t('myInfo.chargePopupTitle')}</h3>
|
||||||
|
<div className="myinfo-popup-field">
|
||||||
|
<label className="myinfo-popup-label">{t('myInfo.chargeAmountLabel')}</label>
|
||||||
|
<div className="myinfo-popup-counter">
|
||||||
|
<button
|
||||||
|
className="myinfo-popup-counter-btn"
|
||||||
|
onClick={() => setChargeAmount(v => String(Math.max(1, Number(v || 0) - 1)))}
|
||||||
|
>−</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="myinfo-popup-input myinfo-popup-input--center"
|
||||||
|
placeholder="0"
|
||||||
|
value={chargeAmount}
|
||||||
|
onChange={e => setChargeAmount(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="myinfo-popup-counter-btn"
|
||||||
|
onClick={() => setChargeAmount(v => String(Number(v || 0) + 1))}
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="myinfo-popup-field">
|
||||||
|
<label className="myinfo-popup-label">{t('myInfo.chargeNoteLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
className="myinfo-popup-textarea"
|
||||||
|
placeholder={t('myInfo.chargeNotePlaceholder')}
|
||||||
|
value={chargeNote}
|
||||||
|
onChange={e => setChargeNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="myinfo-popup-actions">
|
||||||
|
<button className="myinfo-popup-cancel" onClick={() => { setShowChargePopup(false); setChargeAmount(''); setChargeNote(''); }}>{t('myInfo.chargeCancel')}</button>
|
||||||
|
<button
|
||||||
|
className="myinfo-popup-close"
|
||||||
|
disabled={!chargeAmount || isRequesting}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsRequesting(true);
|
||||||
|
try {
|
||||||
|
await requestCreditCharge(Number(chargeAmount), chargeNote);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Charge request failed:', e);
|
||||||
|
} finally {
|
||||||
|
setIsRequesting(false);
|
||||||
|
setChargeSuccess(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRequesting ? t('myInfo.chargeSubmitting') : t('myInfo.chargeSubmit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="myinfo-page">
|
<main className="myinfo-page">
|
||||||
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
|
||||||
|
|
||||||
|
|
@ -110,7 +213,28 @@ const MyInfoContent: React.FC = () => {
|
||||||
|
|
||||||
{activeTab === 'payment' && (
|
{activeTab === 'payment' && (
|
||||||
<div className="myinfo-section">
|
<div className="myinfo-section">
|
||||||
<p className="myinfo-placeholder">{t('myInfo.paymentPlaceholder')}</p>
|
<h2 className="myinfo-section-title">{t('myInfo.creditsTitle')}</h2>
|
||||||
|
<div className="myinfo-credits-card">
|
||||||
|
{isLoadingCredits ? (
|
||||||
|
<p className="myinfo-placeholder">{t('myInfo.creditsLoading')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="myinfo-credits-row">
|
||||||
|
<span className="myinfo-credits-label">{t('myInfo.creditsLabel')}</span>
|
||||||
|
<span className="myinfo-credits-value">{credits !== null ? credits.toLocaleString() : '-'} {t('myInfo.creditsUnit')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="myinfo-credits-row">
|
||||||
|
<p className="myinfo-credits-desc">{t('myInfo.creditsDesc')}</p>
|
||||||
|
<button
|
||||||
|
className="myinfo-credits-charge-btn"
|
||||||
|
onClick={() => setShowChargePopup(true)}
|
||||||
|
>
|
||||||
|
{t('myInfo.creditsChargeBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -250,6 +374,7 @@ const MyInfoContent: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface SoundStudioContentProps {
|
||||||
onStatusChange?: (status: string) => void;
|
onStatusChange?: (status: string) => void;
|
||||||
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
|
||||||
videoGenerationProgress?: number;
|
videoGenerationProgress?: number;
|
||||||
|
onGoToPayment?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
|
||||||
|
|
@ -42,7 +43,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
mId,
|
mId,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
videoGenerationStatus = 'idle',
|
videoGenerationStatus = 'idle',
|
||||||
videoGenerationProgress = 0
|
videoGenerationProgress = 0,
|
||||||
|
onGoToPayment,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedType, setSelectedType] = useState('보컬');
|
const [selectedType, setSelectedType] = useState('보컬');
|
||||||
|
|
@ -533,12 +535,24 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (() => {
|
||||||
|
const isCreditsError = errorMessage.includes('credit');
|
||||||
|
return (
|
||||||
<div className="error-message-new">
|
<div className="error-message-new">
|
||||||
{errorMessage}
|
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
|
||||||
</div>
|
{isCreditsError && (
|
||||||
|
<a
|
||||||
|
href=""
|
||||||
|
className="charge-credits-link"
|
||||||
|
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
|
||||||
|
>
|
||||||
|
{t('soundStudio.chargeCredits')}
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Lyrics */}
|
{/* Right Column - Lyrics */}
|
||||||
<div className="lyrics-column">
|
<div className="lyrics-column">
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,10 @@ export interface UserMeResponse {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserCreditsResponse {
|
||||||
|
credits: number;
|
||||||
|
}
|
||||||
|
|
||||||
// 비디오 목록 아이템
|
// 비디오 목록 아이템
|
||||||
export interface VideoListItem {
|
export interface VideoListItem {
|
||||||
video_id: number;
|
video_id: number;
|
||||||
|
|
@ -375,6 +379,8 @@ export interface UploadHistoryItem {
|
||||||
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
|
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
|
||||||
title: string;
|
title: string;
|
||||||
channel_name: string;
|
channel_name: string;
|
||||||
|
platform_user_id: string | null;
|
||||||
|
platform_username: string | null;
|
||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
uploaded_at: string | null;
|
uploaded_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
TokenExpiredErrorResponse,
|
TokenExpiredErrorResponse,
|
||||||
YTAutoSeoRequest,
|
YTAutoSeoRequest,
|
||||||
YTAutoSeoResponse,
|
YTAutoSeoResponse,
|
||||||
|
UserCreditsResponse,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
@ -651,6 +652,32 @@ export async function getUserMe(): Promise<UserMeResponse> {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자 크레딧 조회
|
||||||
|
export async function getUserCredits(): Promise<UserCreditsResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/user/auth/me/credits`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크레딧 충전 요청
|
||||||
|
export async function requestCreditCharge(amount: number, note: string): Promise<void> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/user/credits/charge-requests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ requested_amount: amount, message: note }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 로그인 여부 확인
|
// 로그인 여부 확인
|
||||||
export function isLoggedIn(): boolean {
|
export function isLoggedIn(): boolean {
|
||||||
return !!getAccessToken();
|
return !!getAccessToken();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue