크레딧기능 추가

feature-credit
김성경 2026-04-28 14:32:55 +09:00
parent 044fd21b2d
commit bdd52ed992
12 changed files with 153 additions and 25 deletions

View File

@ -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;
@ -7990,6 +8000,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 +8071,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 +8488,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%;

View File

@ -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>

View File

@ -217,7 +217,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) {
@ -335,6 +337,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');

View File

@ -12,6 +12,7 @@
"myContents": "My Contents", "myContents": "My Contents",
"myInfo": "My Info", "myInfo": "My Info",
"defaultUser": "User", "defaultUser": "User",
"credits": "Credits left: {{count}} / 3",
"loggingOut": "Logging out...", "loggingOut": "Logging out...",
"logout": "Log Out", "logout": "Log Out",
"tutorialRestart": "Restart Tutorial", "tutorialRestart": "Restart Tutorial",
@ -250,7 +251,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",
@ -474,7 +478,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 +498,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"],

View File

@ -12,6 +12,7 @@
"myContents": "내 콘텐츠", "myContents": "내 콘텐츠",
"myInfo": "내 정보", "myInfo": "내 정보",
"defaultUser": "사용자", "defaultUser": "사용자",
"credits": "잔여 크레딧: {{count}} / 3",
"loggingOut": "로그아웃 중...", "loggingOut": "로그아웃 중...",
"logout": "로그아웃", "logout": "로그아웃",
"tutorialRestart": "튜토리얼 다시 보기", "tutorialRestart": "튜토리얼 다시 보기",
@ -250,7 +251,10 @@
"musicGenerationFailed": "음악 생성에 실패했습니다.", "musicGenerationFailed": "음악 생성에 실패했습니다.",
"multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.", "multipleRetryFailed": "여러 번 시도했지만 음악 생성에 실패했습니다. 다시 시도해주세요.",
"musicGenerationError": "음악 생성 중 오류가 발생했습니다.", "musicGenerationError": "음악 생성 중 오류가 발생했습니다.",
"songRegenerationError": "음악 재생성 중 오류가 발생했습니다." "songRegenerationError": "음악 재생성 중 오류가 발생했습니다.",
"creditsRemaining": "잔여 {{count}}",
"creditsExhausted": "크레딧이 부족합니다.",
"chargeCredits": "크레딧 충전하기"
}, },
"completion": { "completion": {
"back": "뒤로가기", "back": "뒤로가기",
@ -474,7 +478,11 @@
"generateContent": "콘텐츠 생성", "generateContent": "콘텐츠 생성",
"pageDescBefore": "을 통해 도출된 ", "pageDescBefore": "을 통해 도출된 ",
"pageDescAfter": "의 핵심 전략입니다.", "pageDescAfter": "의 핵심 전략입니다.",
"loadingTitle": "브랜드 분석 중" "loadingTitle": "브랜드 분석 중",
"loadingStep1": "브랜드 정보를 수집하는 중...",
"loadingStep2": "수집한 데이터를 분석하는 중...",
"loadingStep3": "핵심 전략을 도출하는 중...",
"loadingStep4": "결과를 정리하는 중..."
}, },
"common": { "common": {
"back": "뒤로가기", "back": "뒤로가기",
@ -502,7 +510,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": "제작한 콘텐츠를 소셜 채널에 업로드해 보세요",

View File

@ -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>

View File

@ -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;
onVideoComplete?: () => 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,
onVideoComplete,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle'); const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
@ -253,6 +255,7 @@ 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'));
} }

View File

@ -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)실제 지표 없음

View File

@ -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,25 @@ 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 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);
} }
@ -467,6 +478,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
songTaskId={songTaskId} songTaskId={songTaskId}
onVideoStatusChange={setVideoGenerationStatus} onVideoStatusChange={setVideoGenerationStatus}
onVideoProgressChange={setVideoGenerationProgress} onVideoProgressChange={setVideoGenerationProgress}
onVideoComplete={refreshCredits}
/> />
); );
default: default:
@ -572,7 +584,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 +597,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'}`}>

View File

@ -42,7 +42,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
mId, mId,
onStatusChange, onStatusChange,
videoGenerationStatus = 'idle', videoGenerationStatus = 'idle',
videoGenerationProgress = 0 videoGenerationProgress = 0,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedType, setSelectedType] = useState('보컬'); const [selectedType, setSelectedType] = useState('보컬');
@ -533,12 +533,20 @@ 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">
{t('soundStudio.chargeCredits')}
</a>
)} )}
</div> </div>
);
})()}
</div>
{/* Right Column - Lyrics */} {/* Right Column - Lyrics */}
<div className="lyrics-column"> <div className="lyrics-column">

View File

@ -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;

View File

@ -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,19 @@ 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 function isLoggedIn(): boolean { export function isLoggedIn(): boolean {
return !!getAccessToken(); return !!getAccessToken();