Compare commits

...

2 Commits

Author SHA1 Message Date
김성경 00ea49b7ed 크레딧 충전 기능 및 UI 수정 2026-05-04 13:47:38 +09:00
김성경 82dcda0038 임시 저장 2026-04-30 14:58:10 +09:00
13 changed files with 492 additions and 34 deletions

242
index.css
View File

@ -9382,6 +9382,232 @@
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 {
background: rgba(255, 255, 255, 0.03);
@ -10620,7 +10846,21 @@
padding: 1.25rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
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 {

View File

@ -12,6 +12,7 @@ interface SocialPostingModalProps {
isOpen: boolean;
onClose: () => void;
video: VideoListItem | null;
onGoToCalendar?: () => void;
}
type PrivacyType = 'public' | 'unlisted' | 'private';
@ -116,7 +117,8 @@ const MiniCalendar: React.FC<{
const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
isOpen,
onClose,
video
video,
onGoToCalendar,
}) => {
const { t } = useTranslation();
const tutorial = useTutorial();
@ -150,6 +152,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 (모달이 닫힌 후에도 유지)
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
const [uploadChannelName, setUploadChannelName] = useState<string>('');
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
// 드롭다운 외부 클릭 시 닫기
useEffect(() => {
@ -300,6 +303,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
// 업로드 정보 저장 (모달이 닫힌 후에도 유지)
setUploadVideoTitle(title.trim());
setUploadChannelName(selectedAcc.display_name);
setUploadIsScheduled(publishTime === 'schedule');
setShowUploadProgress(true);
try {
@ -401,6 +405,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
setUploadErrorMessage(undefined);
setUploadVideoTitle('');
setUploadChannelName('');
setUploadIsScheduled(false);
};
const handleClose = () => {
@ -429,7 +434,8 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
channelName={uploadChannelName || selectedAccount?.display_name || ''}
youtubeUrl={uploadYoutubeUrl}
errorMessage={uploadErrorMessage}
isScheduled={publishTime === 'schedule'}
isScheduled={uploadIsScheduled}
onGoToCalendar={onGoToCalendar}
/>
);

View File

@ -14,6 +14,7 @@ interface UploadProgressModalProps {
youtubeUrl?: string;
errorMessage?: string;
isScheduled?: boolean;
onGoToCalendar?: () => void;
}
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
@ -26,6 +27,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
youtubeUrl,
errorMessage,
isScheduled = false,
onGoToCalendar,
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
@ -151,9 +153,16 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
{/* Footer */}
<div className="upload-progress-footer">
{canClose ? (
<>
<button className="upload-progress-btn primary" onClick={onClose}>
{status === 'completed' ? t('upload.confirm') : t('upload.close')}
</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>
)}

View File

@ -150,7 +150,8 @@
"viewOnYoutube": "View on YouTube",
"confirm": "OK",
"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": {
"hero": {
@ -430,7 +431,22 @@
"connected": "Connected",
"disconnectAccount": "Disconnect",
"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": {
"title": "ADO2 Contents",

View File

@ -150,7 +150,8 @@
"viewOnYoutube": "YouTube에서 보기",
"confirm": "확인",
"close": "닫기",
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요."
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
"goToCalendar": "캘린더에서 확인"
},
"landing": {
"hero": {
@ -430,7 +431,22 @@
"connected": "연결됨",
"disconnectAccount": "연결 해제",
"loadingAccounts": "계정 정보를 불러오는 중...",
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다."
"youtubeExpiredAlert": "YouTube 인증이 만료되었습니다. 재연동 페이지로 이동합니다.",
"creditsTitle": "크레딧 현황",
"creditsLoading": "로딩 중...",
"creditsLabel": "보유 크레딧",
"creditsUnit": "크레딧",
"creditsDesc": "크레딧이 부족하면 관리자에게 충전 요청할 수 있습니다.",
"creditsChargeBtn": "충전하기",
"chargePopupTitle": "크레딧 충전 요청",
"chargeAmountLabel": "충전 크레딧",
"chargeNoteLabel": "기타 내용",
"chargeNotePlaceholder": "관리자에게 전달할 내용을 입력해주세요",
"chargeCancel": "취소",
"chargeSubmit": "요청하기",
"chargeSubmitting": "요청 중...",
"chargeSuccess": "충전 요청이 완료 되었습니다!",
"chargeConfirm": "확인"
},
"ado2Contents": {
"title": "ADO2 콘텐츠",

View File

@ -7,9 +7,10 @@ import SocialPostingModal from '../../components/SocialPostingModal';
interface ADO2ContentsPageProps {
onBack: () => void;
onNavigate?: (item: string) => void;
}
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack, onNavigate }) => {
const { t } = useTranslation();
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
@ -295,6 +296,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
isOpen={uploadModalOpen}
onClose={handleUploadModalClose}
video={uploadTargetVideo}
onGoToCalendar={onNavigate ? () => onNavigate('콘텐츠 캘린더') : undefined}
/>
</div>
);

View File

@ -12,7 +12,7 @@ interface CompletionContentProps {
songTaskId: string | null;
onVideoStatusChange?: (status: 'idle' | 'generating' | 'complete' | 'error') => void;
onVideoProgressChange?: (progress: number) => void;
onVideoComplete?: () => void;
onGoToCalendar?: () => void;
}
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
@ -34,7 +34,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
songTaskId,
onVideoStatusChange,
onVideoProgressChange,
onVideoComplete,
onGoToCalendar,
}) => {
const { t } = useTranslation();
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
@ -47,18 +47,20 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const displayIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const tutorial = useTutorial();
const tutorialIsActiveRef = useRef(tutorial.isActive);
tutorialIsActiveRef.current = tutorial.isActive;
// 영상 생성 중 튜토리얼 트리거 (생성 상태 안내 -> 콘텐츠 정보 -> 내 정보 이동)
useEffect(() => {
const isComplete = videoStatus === 'complete';
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);
} 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);
}
}, [videoStatus, tutorial, tutorial.isActive]);
}, [videoStatus]);
// 소셜 미디어 포스팅 모달
const [showSocialModal, setShowSocialModal] = useState(false);
@ -255,7 +257,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoStatus('complete');
setStatusMessage('');
saveToStorage(videoTaskId, currentSongTaskId, 'complete', videoUrlFromResponse, videoId);
onVideoComplete?.();
} else {
throw new Error(t('completion.videoUrlMissing'));
}
@ -513,6 +514,7 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
<SocialPostingModal
isOpen={showSocialModal}
onClose={handleCloseSocialConnect}
onGoToCalendar={onGoToCalendar}
video={videoUrl && videoDbId ? {
video_id: videoDbId,
store_name: songCompletionData?.businessName || '',

View File

@ -548,9 +548,19 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
</span>
</div>
{/* 채널 아이콘 + 제목 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlatformIcon platform={item.platform} size={18} />
{/* 채널 아이콘 + 채널명 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<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={{
fontFamily: 'Pretendard, sans-serif', fontWeight: 600, fontSize: 14,
color: '#e5f1f2', lineHeight: 1.4,
@ -559,7 +569,6 @@ const ContentCalendarContent: React.FC<ContentCalendarContentProps> = ({ onNavig
}}>
{item.title}
</span>
</div>
{/* 실패 메시지 */}
{isFailed && item.error_message && (

View File

@ -118,6 +118,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
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 refreshCredits = useCallback(async () => {
@ -247,7 +248,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setIsAnalysisComplete(true);
} catch (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 입력으로 돌아가기
}
};
@ -366,8 +368,15 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
}, [activeItem]);
// 결제 탭으로 바로 이동하는 핸들러 (크레딧 충전하기 버튼용)
const handleGoToPayment = () => {
setMyInfoInitialTab('payment');
setActiveItem('내 정보');
};
// 네비게이션 핸들러 - "새 프로젝트 만들기" 클릭 시 기존 프로젝트 데이터 초기화
const handleNavigate = (item: string) => {
setMyInfoInitialTab(undefined);
if (item === '새 프로젝트 만들기') {
// 기존 프로젝트 데이터 초기화
clearAllProjectStorage();
@ -384,6 +393,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
setAnalysisError(null);
}
setActiveItem(item);
refreshCredits();
};
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
@ -454,6 +464,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
mId={analysisData?.m_id ?? 0}
videoGenerationStatus={videoGenerationStatus}
videoGenerationProgress={videoGenerationProgress}
onGoToPayment={handleGoToPayment}
onStatusChange={(status: string) => {
if (status === 'generating_song' && !tutorial.hasSeen(TUTORIAL_KEYS.SOUND_LYRICS)) {
setTimeout(() => tutorial.startTutorial(TUTORIAL_KEYS.SOUND_LYRICS), 400);
@ -478,7 +489,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress}
onVideoComplete={refreshCredits}
onGoToCalendar={() => handleNavigate('콘텐츠 캘린더')}
/>
);
default:
@ -496,12 +507,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
return (
<ADO2ContentsPage
onBack={() => setActiveItem('새 프로젝트 만들기')}
onNavigate={handleNavigate}
/>
);
case '콘텐츠 캘린더':
return <ContentCalendarContent onNavigate={handleNavigate} />;
case '내 정보':
return <MyInfoContent />;
return <MyInfoContent initialTab={myInfoInitialTab} />;
case '새 프로젝트 만들기':
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
if (wizardStep === 0 || wizardStep === -1) {

View File

@ -1,18 +1,53 @@
import React, { useState, useEffect } from 'react';
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';
type TabType = 'basic' | 'payment' | 'business';
const MyInfoContent: React.FC = () => {
interface MyInfoContentProps {
initialTab?: TabType;
}
const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('business');
const [activeTab, setActiveTab] = useState<TabType>(initialTab || 'business');
const [businessUrl, setBusinessUrl] = useState('');
const [socialAccounts, setSocialAccounts] = useState<SocialAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
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(() => {
@ -84,6 +119,74 @@ const MyInfoContent: React.FC = () => {
];
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">
<h1 className="myinfo-title">{t('myInfo.title')}</h1>
@ -110,7 +213,28 @@ const MyInfoContent: React.FC = () => {
{activeTab === 'payment' && (
<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>
)}
@ -250,6 +374,7 @@ const MyInfoContent: React.FC = () => {
)}
</div>
</main>
</>
);
};

View File

@ -19,6 +19,7 @@ interface SoundStudioContentProps {
onStatusChange?: (status: string) => void;
videoGenerationStatus?: 'idle' | 'generating' | 'complete' | 'error';
videoGenerationProgress?: number;
onGoToPayment?: () => void;
}
type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polling' | 'complete' | 'error';
@ -43,6 +44,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
onStatusChange,
videoGenerationStatus = 'idle',
videoGenerationProgress = 0,
onGoToPayment,
}) => {
const { t } = useTranslation();
const [selectedType, setSelectedType] = useState('보컬');
@ -539,7 +541,11 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
<div className="error-message-new">
{isCreditsError ? t('soundStudio.creditsExhausted') : errorMessage}
{isCreditsError && (
<a href="#" className="charge-credits-link">
<a
href=""
className="charge-credits-link"
onClick={e => { e.preventDefault(); onGoToPayment?.(); }}
>
{t('soundStudio.chargeCredits')}
</a>
)}

View File

@ -379,6 +379,8 @@ export interface UploadHistoryItem {
status: 'pending' | 'uploading' | 'completed' | 'failed' | 'scheduled' | 'cancelled';
title: string;
channel_name: string;
platform_user_id: string | null;
platform_username: string | null;
scheduled_at: string | null;
uploaded_at: string | null;
created_at: string;

View File

@ -665,6 +665,19 @@ export async function getUserCredits(): Promise<UserCreditsResponse> {
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 {
return !!getAccessToken();