크레딧 충전 기능 및 UI 수정
parent
82dcda0038
commit
00ea49b7ed
226
index.css
226
index.css
|
|
@ -9382,6 +9382,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);
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,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",
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,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 콘텐츠",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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;
|
||||||
onVideoComplete?: () => void;
|
|
||||||
onGoToCalendar?: () => void;
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +34,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
songTaskId,
|
songTaskId,
|
||||||
onVideoStatusChange,
|
onVideoStatusChange,
|
||||||
onVideoProgressChange,
|
onVideoProgressChange,
|
||||||
onVideoComplete,
|
|
||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -259,7 +257,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
||||||
setVideoStatus('complete');
|
setVideoStatus('complete');
|
||||||
setStatusMessage('');
|
setStatusMessage('');
|
||||||
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
|
||||||
onVideoComplete?.();
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t('completion.videoUrlMissing'));
|
throw new Error(t('completion.videoUrlMissing'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
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 [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 () => {
|
const refreshCredits = useCallback(async () => {
|
||||||
|
|
@ -247,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 입력으로 돌아가기
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -366,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();
|
||||||
|
|
@ -455,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);
|
||||||
|
|
@ -479,7 +489,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
songTaskId={songTaskId}
|
songTaskId={songTaskId}
|
||||||
onVideoStatusChange={setVideoGenerationStatus}
|
onVideoStatusChange={setVideoGenerationStatus}
|
||||||
onVideoProgressChange={setVideoGenerationProgress}
|
onVideoProgressChange={setVideoGenerationProgress}
|
||||||
onVideoComplete={refreshCredits}
|
|
||||||
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -504,7 +513,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -43,6 +44,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
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('보컬');
|
||||||
|
|
@ -539,7 +541,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
||||||
<div className="error-message-new">
|
<div className="error-message-new">
|
||||||
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
|
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
|
||||||
{isCreditsError && (
|
{isCreditsError && (
|
||||||
<a href="#" className="charge-credits-link">
|
<a
|
||||||
|
href=""
|
||||||
|
className="charge-credits-link"
|
||||||
|
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
|
||||||
|
>
|
||||||
{t('soundStudio.chargeCredits')}
|
{t('soundStudio.chargeCredits')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -665,6 +665,19 @@ export async function getUserCredits(): Promise<UserCreditsResponse> {
|
||||||
return response.json();
|
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